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/LAUNCHER-HANDOFF.md b/LAUNCHER-HANDOFF.md new file mode 100644 index 0000000..7d21099 --- /dev/null +++ b/LAUNCHER-HANDOFF.md @@ -0,0 +1,95 @@ +# Launcher tech — what works, what doesn't, and what's left (2026-08-03) + +Thirteen attempts, all rejected. Best 45% vs live v22. This is everything you +need to finish it without repeating any of it. Branch: `claude/measurement-fixes`. + +## What Pantheon actually does (traced from replays, not guessed) + +``` +t1 launcher built 2 tiles from THEIR core (18 from ours), OFF the core->core axis +t2 builder 3 (3,4) -> (4,10) jumped 7 +t3 builder 5 (2,4) -> (4,10) jumped 8 <- same landing tile, a rally point +t4 builder 8 (4,4) -> (1,6) jumped 5 +t5 builder 11 (4,15)-> (1,17) jumped 5 +then the thrown builder BOLTS: 14,13,12,11,10,9,8,7,6,5 — one tile per turn, +no detour, no economy work — and the first gunner lands t13 at distance 4. +``` + +Three to four throws per game, all in the first five turns. The throw buys ~6 +tiles; the bolt does the rest. Without it that is a t21 gun. + +Engine facts, measured directly: +- pickup range 1-2 tiles, throw up to 7, 1-turn cooldown, 20 Ti (cost scales) +- `LAUNCHER_VISION_RADIUS_SQ` 26 vs a builder's 20 +- ammo converts 1:1, same turn, once per team per turn + +## THREE LANDMINES IN OUR CODEBASE — hit these and the mechanism silently dies + +1. **Non-builder entities have no map.** Every entity gets its own `Player` and + only `builderBot()` calls `setupMap()`, so in a launcher/gunner/sentinel + `mapPf.enemyCorePos` and `teamCore` are permanently `None`. A throw routine + written against `mapPf.enemyCorePos` compiles, runs, and never fires — no + error. Cost me a whole day. Fix is in `exp_v23_fix_storemap`: builders publish + the enemy core to **slot 9**, non-builders read it. Slots 0-8 are taken + (0 numSpawned, 1-6 map share, 7 team core, 8 symmetry mask). +2. **`mapPf.moveTo` dead-ends against our own conveyor ring** — our conveyors are + impassable to us. It then waits `2 + (id % 8)` turns (3-10!) before trying a + **random** direction. HANDOFF records this costing aegis-v1 seven turns "2 + tiles short". Sidestep fix in `exp_v23_fix_stuck` (neutral, safe). +3. **Our own buildings block our own exit.** Placing the pad on the tile nearest + the enemy — the obvious choice — parks it in the doorway every builder walks + through. Traced: a builder bouncing 22<->21 for eight turns where the pad-less + parent marched straight out. Put the pad **one tile perpendicular** to the + core->enemy axis. 41% of our conveyors also land in that corridor. + +## What each attempt proved (don't redo these) + +| variant | result | what it proved | +|---|---|---| +| v1 pad gated on resources+role | 52% | pad went up too late to matter | +| v2 + routing, 6 pads by accident | — | **pad COUNT is not the issue**: six beat one by nothing | +| v3 pad t1, 1/game, right placement | — | **0 launches** — landmine 1 | +| v4 target via store slot 9 | — | 8.5 launches from t2, but first gun t24->t26: it was throwing the ECONOMY crew | +| v5 attackers claim rides (slot 10) | 40% | 17.1 launches, first gun t20. Riders kept coming back for more | +| v6 one-ride-per-builder flag | — | 0.8 launches: flag set on CLAIM, and one claim survives per turn, so losers disqualify themselves | +| v7 geometry (throw must clear the radius) | 38% | 8.8 launches, first gun t20 | +| v8 rider given the store fallback | — | **deadlock**: riders waited at the pad t6->t89 | +| v9 wait timeout | — | oscillation 21<->22 all game | +| v11 no detour, claim only if already beside it | 45% | **3.8 launches/game = Pantheon's exact rate** | +| v12 pad off the exit lane | 45% | bolt restored (22,21,20,18,17,...) — landmine 3 | +| combo v7 + forward armour | 40% | armour does not rescue it | +| eye (sensor only, no throwing) | 43% | **symmetry DOES resolve 2-3 turns earlier** (measured: 1,2,3,3,3 vs 4,5,5,5,5) — real, but does not pay | + +## What is still unsolved — the actual remaining problem + +**Our thrown builders do not bolt.** Pantheon's marches one tile per turn +straight in. Ours lands and re-enters `runAttack`, whose `findGunnerSpot` pulls +it sideways, and if it drifts back near the pad the ride logic grabs it again. +Every version that fixed the ride broke the march and vice versa. + +If you want to finish this, that is the piece: **a thrown builder should be in a +committed "bolt" state — straight-line march to the enemy core, no gunner-spot +search, no re-riding — until it is inside the firing band.** Detect the landing +(position jumped >1 tile in a turn) and latch the state. + +## Also worth knowing + +- v24 beat Pantheon 3-2 and CtrlAltDefeat 3-2 in fresh unrated scrims; v22 went + 0-5 vs Pantheon the same day. v24 is NOT a regression — the raw 80%-vs-60% + ladder gap is schedule (v24 played 40 rounds vs Pantheon). Like-for-like it is + -5 points at 1.2 sigma. +- Our first gun vs Pantheon is now **t21 to their t14** with zero launchers, + where this morning it was t31 to t13. v23/v24 already bought most of the tempo + the launcher was supposed to buy — which is why the launcher's remaining upside + may be small. +- **The one asymmetry left: they build 3.2 barriers/game, we build none.** + +## Tools added + +- `builder_trace.py` — attributes each building to the builder standing next to + it, giving a per-builder timeline. This is what cracked Orizon, Prompt + Engineers and Pantheon. Use it before theorising. +- `heal_audit.py` — per-side core damage vs healing, offline, no API needed. +- oarena (github.com/frkns/oarena) for 2D replay viewing. Its venv needs a `.pth` + pointing at the fcode site-packages, because a venv created from another venv + does not inherit it. `oarena serve` on :7878. diff --git a/MEASUREMENT-FINDINGS.md b/MEASUREMENT-FINDINGS.md new file mode 100644 index 0000000..a39e1aa --- /dev/null +++ b/MEASUREMENT-FINDINGS.md @@ -0,0 +1,244 @@ +# 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). + +## 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. + + +## 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 +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/V29-PLAN.md b/V29-PLAN.md new file mode 100644 index 0000000..625d878 --- /dev/null +++ b/V29-PLAN.md @@ -0,0 +1,119 @@ +# v29 diagnosis and iteration plan — 2026-08-04 + +Status: **Erebus #2 of 92, rating 1966, v29 "routing + threat avoidance" +419-81 (84%) over 500 rounds.** Pantheon #1 at 2016 — a 50-point gap. + +Evidence base: 70 replays downloaded from v29's own losing matches (Pantheon, +Pareto-ion, team lazy, Coreflood, the one piece) plus the full per-version +ladder record. Nothing below is inferred from a single game. + +## 1. Where we actually lose + +Only one matchup is below 50%: + +| opponent | record | rate | +|---|---|---| +| **Pantheon (#1)** | 24-26 | **48%** | +| the one piece | 5-5 | 50% (n=10) | +| Coreflood (#5) | 17-8 | 68% | +| team lazy (#4) | 18-7 | 72% | +| Pareto-ion (#3) | 27-8 | 77% | +| everyone else | — | 80-100% | + +**23 of 26 losses are `core_destroyed`, median t62, and 12 of them end before +t60.** We are not being out-grinded; we are being killed early. + +## 2. The measured gap, side by side (same games) + +| side | 1st gun | on core | built | conv | harv | barriers | launchers | +|---|---|---|---|---|---|---|---| +| **US (v29)** | **t22** | **5.3** | **17.7** | 10.0 | 2.1 | **0.0** | **0.0** | +| Pantheon | **t7** | 12.1 | 22.2 | 7.0 | 2.3 | 3.0 | 1.0 | +| Pareto-ion | t16 | 7.7 | 16.1 | 8.3 | 2.3 | 4.1 | 1.5 | +| team lazy | t14 | 10.8 | 13.1 | 7.3 | 1.8 | 0.0 | 0.0 | +| Coreflood | t28 | 9.8 | 17.6 | 7.5 | 1.8 | 0.0 | 0.1 | +| the one piece | t30 | 6.6 | 8.8 | 4.6 | 1.4 | 0.0 | 0.4 | + +Three facts fall out: + +1. **Our gun-to-core conversion is the worst on the board: 30% (5.3 of 17.7).** + team lazy converts 82%, the one piece 75%, Pantheon 55%. We build the most + guns of anyone and land the fewest proportionally. This is the single largest + number in the table. +2. **We are the slowest to first gun except two: t22.** Pantheon t7 — fifteen + turns. team lazy t14, Pareto-ion t16. +3. **We build zero barriers.** The two teams above/near us build 3.0 and 4.1. + +We also lay the most conveyor (10.0) of anyone. + +## 3. Plan + +### Phase 0 — diagnostics only, no code (about 20 minutes) + +These are cheap and they decide which Phase 1 experiments are worth running. +Running experiments before these is how the last two days produced ~20 +rejections. + +- **D1. Where do the 12.4 non-converting guns go?** Distance-to-enemy-core + histogram for every gun we build, split by game phase. If they cluster + mid-map, it is target selection; if they cluster at home, it is defensive + spend; if they cluster at 7-12, it is arriving-and-dying. +- **D2. What kills us before t60?** For the 12 sub-t60 losses: when does the + first enemy gun appear near our core, how many, and what were our builders + doing that turn. +- **D3. Do our guns die or just miss?** Gun lifetime and total core damage dealt + per game, us vs each opponent. Distinguishes "placed badly" from "placed fine + and killed". + +### Phase 1 — experiments, in priority order + +Each is a single mechanism, forked from a frozen `bots/live-v29-control` +extracted byte-for-byte from the active submission. One at a time, no stacking +until each passes alone. + +- **E1 — conversion (highest expected value).** Target the 30%. Exact change + depends on D1: if guns are going mid-map, restrict discretionary placement to + seats that pass `can_fire_from` against a core tile; if they are dying on + arrival, that is E3 instead. + *Prior art: `exp_core_pressure` tried this on the v8 lineage and gated 46%, + losing 9 points against rushers because it stripped home defence. Any version + must keep the home counter-gun path intact.* +- **E2 — first-gun tempo t22 -> ~t14.** Not the launcher: thirteen launcher + variants were rejected and `LAUNCHER-HANDOFF.md` documents why. The cheaper + question is why our first attacker takes 22 turns when team lazy manages 14 + with no launcher either. +- **E3 — barriers at the seats we hold.** We build zero; Pantheon 3.0, + Pareto-ion 4.1. Only worth building if D3 says our guns are dying rather than + missing. + *Prior art: `aegis-v1` (reactive armour at home) and `bastion` both rejected; + `exp_v23_fwd_armor` on the v22 lineage gated 47%. Different lineage now, and + D3 decides whether the premise holds at all.* +- **E4 — economy right-sizing.** We lay 10.0 conveyors to Pantheon's 7.0 and + team lazy's 7.3. Lowest priority: it is the smallest gap and the most likely + to be load-bearing. + +### Phase 2 — gates and promotion criteria + +Per `WORKFLOW.md`, plus what the noise-floor work established: + +- frozen parent `bots/live-v29-control`, 336 rounds, both sides, `--tle 10` +- **promote only if the 95% CI excludes 50%** — at n=336 that is roughly 55%+. + A 168-round result can eliminate but never promote. +- fresh parent control beside every opponent gate; a number means nothing alone +- mechanism must be shown active in replay before its win rate is believed +- anything that passes goes to an unrated scrim vs Pantheon and Pareto-ion + before it is considered for the ladder + +### What we will NOT do again + +Documented rejections, with reasons, so they are not rebuilt under new names: +launcher-as-transport (13 variants, best 45%), launcher-as-sensor (43%, effect +real but 2-3 turns only), flanking (44%), placement radius (46%), heal-lock +retreat (52%), pre-armour seat denial (51%), phase-ordered economy on the old +lineage (34%), zero-economy delivery (27%). + +## 4. Success criterion + +The only number that matters: **Pantheon 48% -> 55%+.** Everything else is +already 68-100%, and beating the field harder does not close a 50-point rating +gap to the one team we cannot beat. diff --git a/WORKFLOW.md b/WORKFLOW.md index 62762ec..49c33ad 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -109,6 +109,69 @@ 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. +## ENGINE 2.3.4 REBALANCE — every pre-patch number is stale (2026-08-04) + +`fcode 2.3.3 -> 2.3.4` changed six constants. Gunners were nerfed on FOUR axes at +once and sentinels buffed on two: + +| constant | 2.3.3 | 2.3.4 | +|---|---:|---:| +| GUNNER_BASE_COST | 10 | **20** | +| GUNNER_DAMAGE | 10 | **7** | +| GUNNER_AMMO_COST | 2 | **4** | +| GUNNER_MAX_HP | 40 | **25** | +| SENTINEL_FIRE_COOLDOWN | 3 | **2** | +| SENTINEL_MAX_HP | 30 | **40** | + +That inverts the unit economy: + +| | dmg/turn | dmg/ammo | dmg/Ti | HP | reach | +|---|---:|---:|---:|---:|---| +| gunner | 7.0 | 1.75 | 0.35 | 25 | cardinal 3, diagonal 2 | +| **sentinel** | **9.0** | 1.80 | **0.60** | **40** | **cardinal 5, diagonal 4** | + +Sentinels are now better on every axis except cooldown. Reach was MEASURED with a +probe bot, not assumed: `gunnerLines` in `mapPathfinding.py` is hardcoded 3/2 and +a gunner seat is NOT a sentinel seat, so sentinel work needs its own seat list +(`sentinelSpots` in `bots/exp_v31_sentinel`). + +A 500 HP core now costs ~280 ammo to kill, up from 100. Ammo policies tuned +before the patch are probably the binding constraint now, and attack gates sized +for 10-Ti gunners (`resources >= 80`) are not sized for 30-Ti sentinels. + +## NOISE FLOOR — TWO identical-code draws now bracket it (2.3.4 and 2.3.6) + +| engine | identical code, 336 games | core kills | +|---|---|---| +| 2.3.4 | **54%** (CI 49-60) | 162-142 (+20) | +| 2.3.6 | **46%** (CI 41-52) | 138-158 (-20) | + +**Identical code spans 46-54% and its kill differential swings +20 to -20.** +- Treat ANY result between 46% and 54% at n=336 as nothing. +- **KILL DIFFERENTIALS CARRY NO INFORMATION.** They move with the win rate and + identical code produces both signs. Never quote one as corroboration. +- The 55% promote bar sits one point above what identical code reached on 2.3.4. + For real confidence want ~58%+. + +2.3.5/2.3.6 changed NO game constants (verified by diffing GameConstants out of +the 2.3.6 wheel) - they are engine/CLI fixes. The 2.3.4 gunner rebalance below is +still the live balance. + +## NOISE FLOOR ON 2.3.4 — rerun after the patch, and it is WIDE + +`bots/live-v30-twin` is byte-identical to `bots/live-v30-control`. Gated against +itself on 2.3.4, 336 games: + +**54% (CI 49-60), core kills 162-142.** + +- **Treat anything under ~57-58% at n=336 as unproven.** Identical code reached + 54%, one point under the promote bar. +- **KILL DIFFERENTIALS ARE NOT INDEPENDENT CORROBORATION.** Identical code + produced +20 (162-142). A candidate showing a positive differential alongside a + ~52% win rate has shown nothing; the two numbers move together. +- Rerun this control after every engine bump. It is the only way to know what a + number means. + ## Promotion procedure 1. Pass the frozen-parent and all broad gates in the required cycle. diff --git a/artifacts/meta/kfort_d6473f1.zip b/artifacts/meta/kfort_d6473f1.zip new file mode 100644 index 0000000..f40dda7 Binary files /dev/null and b/artifacts/meta/kfort_d6473f1.zip differ 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/artifacts/submissions/v19-route-proof.zip b/artifacts/submissions/v19-route-proof.zip new file mode 100644 index 0000000..35a86a9 Binary files /dev/null and b/artifacts/submissions/v19-route-proof.zip differ diff --git a/artifacts/submissions/v20-near-core-finish.zip b/artifacts/submissions/v20-near-core-finish.zip new file mode 100644 index 0000000..dd2c9bf Binary files /dev/null and b/artifacts/submissions/v20-near-core-finish.zip differ diff --git a/artifacts/submissions/v21-opportunistic-trunk-repair.zip b/artifacts/submissions/v21-opportunistic-trunk-repair.zip new file mode 100644 index 0000000..a446148 Binary files /dev/null and b/artifacts/submissions/v21-opportunistic-trunk-repair.zip differ diff --git a/artifacts/submissions/v21-server.zip b/artifacts/submissions/v21-server.zip new file mode 100644 index 0000000..09ecd14 Binary files /dev/null and b/artifacts/submissions/v21-server.zip differ diff --git a/artifacts/submissions/v22-damaged-core-eco-release.zip b/artifacts/submissions/v22-damaged-core-eco-release.zip new file mode 100644 index 0000000..ae04e5d Binary files /dev/null and b/artifacts/submissions/v22-damaged-core-eco-release.zip differ diff --git a/artifacts/submissions/v22-server.zip b/artifacts/submissions/v22-server.zip new file mode 100644 index 0000000..d8bedfd Binary files /dev/null and b/artifacts/submissions/v22-server.zip differ diff --git a/artifacts/submissions/v29.zip b/artifacts/submissions/v29.zip new file mode 100644 index 0000000..4d068a4 Binary files /dev/null and b/artifacts/submissions/v29.zip differ diff --git a/artifacts/submissions/v30.zip b/artifacts/submissions/v30.zip new file mode 100644 index 0000000..1f2ad6e Binary files /dev/null and b/artifacts/submissions/v30.zip differ diff --git a/artifacts/submissions/v37.zip b/artifacts/submissions/v37.zip new file mode 100644 index 0000000..9119adf Binary files /dev/null and b/artifacts/submissions/v37.zip differ diff --git a/bolt-v30.zip b/bolt-v30.zip new file mode 100644 index 0000000..a9b4447 Binary files /dev/null and b/bolt-v30.zip differ diff --git a/bot.zip b/bot.zip index 9d1853f..a8e302f 100644 Binary files a/bot.zip and b/bot.zip differ 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_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 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_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_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_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_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_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_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_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_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_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_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_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/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_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 new file mode 100644 index 0000000..9ca6375 --- /dev/null +++ b/bots/exp_late_route_repair/README.md @@ -0,0 +1,33 @@ +# 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 +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_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_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_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_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_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_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_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/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 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 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/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_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 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_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_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_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_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/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_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_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_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/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/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_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/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/bots/exp_v17_gunner_control/README.md b/bots/exp_v17_gunner_control/README.md new file mode 100644 index 0000000..b4a832a --- /dev/null +++ b/bots/exp_v17_gunner_control/README.md @@ -0,0 +1,23 @@ +# 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 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_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/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_v23_combo/README.md b/bots/exp_v23_combo/README.md new file mode 100644 index 0000000..9d792a2 --- /dev/null +++ b/bots/exp_v23_combo/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_v23_combo/main.py b/bots/exp_v23_combo/main.py new file mode 100644 index 0000000..d77ed00 --- /dev/null +++ b/bots/exp_v23_combo/main.py @@ -0,0 +1,928 @@ +"""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 +# LAUNCH PAD, done Pantheon's way. Traced from their 0-5 sweep of our v22: +# launcher built at t1, 3-4 tiles from their own core +# t2 builder (3,4)->(4,10) d7 t3 (2,4)->(4,10) d8 +# t4 (4,4)->(1,6) d5 t5 (4,15)->(1,17) d5 +# They build the pad on TURN ONE and throw every newly spawned builder 7-8 tiles +# on the 1-turn cooldown, several to the same staging tile. Their first gun +# lands t13; ours lands t22-31 because our builders walk. +# Two earlier attempts failed for implementation reasons, not because the +# mechanic is wrong: v1 gated the pad behind `resources > 120` AND the defender +# role so it went up late, and placed it 1-2 tiles from the core instead of the +# 3-4 where spawning builders actually stand. v2 added routing and still sat at +# t22 - and accidentally built 6 pads, proving pad COUNT was never the issue. +# Timing and placement were. +LAUNCHPAD_ROUND_LIMIT = 8 # pad goes up immediately or not at all +LAUNCHPAD_MIN_D = 2 # far enough out that spawned builders reach it +LAUNCHPAD_MAX_D = 4 +LAUNCH_MIN_GAIN = 3 +# v3 got the pad right - one, at t1, 2-4 tiles out - and still launched ZERO +# times. Cause: every entity gets its own Player, and LAUNCHERS NEVER CALL +# setupMap(), so a launcher's mapPf.enemyCorePos is permanently None and the +# throw routine returned immediately. (mapPathfinding.py notes the same trap for +# gunners.) Builders do have the map, so they publish the target here and the +# launcher reads it. Slots 0-8 are taken: 0 numSpawned, 1-6 map share, 7 team +# core, 8 symmetry mask. +ENEMY_CORE_SLOT = 9 +# v4 launched 8.5 times a game starting t2 - mechanically identical to Pantheon - +# and first gun still went t24 -> t26. The pad throws ANY builder in pickup +# range, and in the opening that is mostly the economy crew, so we were flinging +# harvesters and routers across the map. Attackers now walk onto the pad +# deliberately and the launcher only throws a builder that is standing on the +# claim tile, so economy units working near the core are never picked up. +LAUNCH_CLAIM_SLOT = 10 +# v5 launched 17.1x/game (Pantheon: 3-4) and gated 40%: attackers drifted back +# inside the claim radius and rode again and again, and the round trip cost more +# than the 7 tiles bought. v6 tried a one-ride-per-builder flag and collapsed to +# 0.8 launches, because the flag was set when a builder CLAIMED and only one +# claim per turn survives the single slot - everyone who lost the race marked +# itself done and never retried. +# The geometry does the job without a flag: only throw to a tile that lands the +# builder OUTSIDE the claim radius. A thrown builder is then never eligible +# again, so each rides once, and nobody is disqualified by losing a race. +# Slot 7 already holds our own core, which is what a map-less launcher needs. +# THE PACKAGE. Measured separately, each half loses: +# launcher only 38-40% (two implementations) - faster first gun t24 -> t20, +# but kills 96-198: builders arrive early and ALONE and die. +# armour only 47% - guns survive better but still arrive late. +# Pantheon runs both: a t1 pad throwing builders from t2, AND 26 barriers a game +# placed 100% within 6 tiles of OUR core to keep the arrivals alive. The pad +# buys tempo; the armour is what makes early arrival survivable. Neither is the +# mechanism on its own. +TEAM_CORE_SLOT = 7 +LAUNCH_MIN_EXIT = 7 +FWD_ARMOR_TITANIUM_FLOOR = 100 +FWD_ARMOR_BAND = 6 # must land at least this far from our own core + +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) + elif etype == EntityType.LAUNCHER: + self.runLauncher(ct) + + def _publish_enemy_core(self, ct: Controller) -> None: + """Share the target so map-less entities (launchers) can use it.""" + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return + ct.write_store(ENEMY_CORE_SLOT, target.x * 32 + target.y) + + def runLauncher(self, ct: Controller) -> None: + """Throw any friendly builder in pickup range toward their core. + + Reads the target from the store: a launcher has no map of its own. + """ + compact = ct.read_store(ENEMY_CORE_SLOT) + if compact == 0: + return + target = Position(compact // 32, compact % 32) + me = ct.get_position() + myTeam = ct.get_team() + tiles = ct.get_attackable_tiles_from(me, Direction.NORTH, EntityType.LAUNCHER) + if not tiles: + return + claim = ct.read_store(LAUNCH_CLAIM_SLOT) + if claim == 0: + return # nobody is asking for a ride this turn + want = Position(claim // 32, claim % 32) + for uid in ct.get_nearby_units(): + if (ct.get_team(uid) != myTeam + or ct.get_entity_type(uid) != EntityType.BUILDER_BOT): + continue + bot = ct.get_position(uid) + if bot.x != want.x or bot.y != want.y: + continue # only the builder that claimed the ride + here = abs(bot.x - target.x) + abs(bot.y - target.y) + homeCompact = ct.read_store(TEAM_CORE_SLOT) + home = (Position(homeCompact // 32, homeCompact % 32) + if homeCompact else None) + best, bestD = None, here - LAUNCH_MIN_GAIN + for t in tiles: + if home is not None: + if abs(t.x - home.x) + abs(t.y - home.y) < LAUNCH_MIN_EXIT: + continue # too short: they would ride again + d = abs(t.x - target.x) + abs(t.y - target.y) + if d < bestD and ct.can_launch(bot, t): + best, bestD = t, d + if best is not None: + ct.launch(bot, best) + return + + def _build_launchpad(self, ct: Controller, myLoc: Position) -> bool: + """Turn-one pad, 2-4 tiles out on the enemy side. Any builder, no role.""" + if ct.get_current_round() > LAUNCHPAD_ROUND_LIMIT or not ct.can_act(): + return False + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return False + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + return False # one is enough; vision covers the home area + best = None + for d in CARDINALS: + spot = myLoc.add(d) + dc = abs(spot.x - teamCore.x) + abs(spot.y - teamCore.y) + if not (LAUNCHPAD_MIN_D <= dc <= LAUNCHPAD_MAX_D): + continue + if not ct.can_build_launcher(spot): + continue + key = abs(spot.x - target.x) + abs(spot.y - target.y) + if best is None or key < best[0]: + best = (key, spot) + if best is None: + return False + ct.build_launcher(best[1]) + return True + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + self._publish_enemy_core(ct) + if self._build_launchpad(ct, myLoc): + return + 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 _claim_ride(self, ct: Controller, myLoc: Position) -> bool: + """Attacker-only: walk to the pad and announce that we want the throw.""" + teamCore = self.mapPf.teamCore + target = self.mapPf.enemyCorePos + if teamCore is None or target is None: + return False + if abs(myLoc.x - teamCore.x) + abs(myLoc.y - teamCore.y) > 6: + return False # already out on the map; keep marching + myTeam = ct.get_team() + pad = None + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + pad = ct.get_position(b) + break + if pad is None: + return False + d = abs(myLoc.x - pad.x) + abs(myLoc.y - pad.y) + if d > 2: + self.mapPf.moveTo(ct, pad) + return True + ct.write_store(LAUNCH_CLAIM_SLOT, myLoc.x * 32 + myLoc.y) + return True # stand still this turn and get thrown + + 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 self._claim_ride(ct, myLoc): + return + 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 + if self._forward_armor(ct, myLoc): + 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 _forward_armor(self, ct: Controller, myLoc: Position) -> bool: + enemyCore = self.mapPf.enemyCorePos + if enemyCore is None or not ct.can_act(): + return False + if ct.get_global_resources() <= FWD_ARMOR_TITANIUM_FLOOR: + return False + if abs(myLoc.x - enemyCore.x) + abs(myLoc.y - enemyCore.y) > FWD_ARMOR_BAND: + return False + myTeam = ct.get_team() + threat = None + 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 myLoc in ct.get_attackable_tiles_from( + ct.get_position(b), ct.get_direction(b), btype): + threat = ct.get_position(b) + break + if threat is None: + return False + best = None + for d in CARDINALS: + spot = myLoc.add(d) + if ct.get_tile_building_id(spot) is not None: + continue + if not ct.can_build_barrier(spot): + continue + key = abs(spot.x - threat.x) + abs(spot.y - threat.y) + if best is None or key < best[0]: + best = (key, spot) + if best is None: + return False + ct.build_barrier(best[1]) + return True + + 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_v23_combo/mapPathfinding.py b/bots/exp_v23_combo/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v23_combo/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_v23_fix_lane/main.py b/bots/exp_v23_fix_lane/main.py new file mode 100644 index 0000000..a5c2a2d --- /dev/null +++ b/bots/exp_v23_fix_lane/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_v23_fix_lane/mapPathfinding.py b/bots/exp_v23_fix_lane/mapPathfinding.py new file mode 100644 index 0000000..0fa49f9 --- /dev/null +++ b/bots/exp_v23_fix_lane/mapPathfinding.py @@ -0,0 +1,750 @@ +"""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 + # FIX 3: KEEP THE DOOR OPEN. Our own conveyors are impassable to our own + # units, and 41% of this bot's conveyors (5.75 a game) land within two + # tiles of the core->enemy axis - the corridor every attacker walks out + # through. Blocking the corridor outright would starve the economy, since + # that is often where the ore is, so this reserves only the single-tile + # lane straight at the enemy, out to 6 tiles. Everything else is + # unchanged. + if self._blocksExitLane(curEnd): + return False + 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 _blocksExitLane(self, tile: Position) -> bool: + """True if this tile sits in the one-wide lane from our core at theirs.""" + core = self.teamCore + foe = self.enemyCorePos + if core is None or foe is None: + return False + dx, dy = foe.x - core.x, foe.y - core.y + length = (dx * dx + dy * dy) ** 0.5 + if length < 1: + return False + vx, vy = tile.x - core.x, tile.y - core.y + along = (vx * dx + vy * dy) / length + perp = abs(vx * dy - vy * dx) / length + return 1 <= along <= 6 and perp < 1 + + 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_v23_fix_storemap/main.py b/bots/exp_v23_fix_storemap/main.py new file mode 100644 index 0000000..f831336 --- /dev/null +++ b/bots/exp_v23_fix_storemap/main.py @@ -0,0 +1,746 @@ +"""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 +# FIX 1: NON-BUILDER ENTITIES HAVE NO MAP. +# Every entity gets its own Player instance and only builderBot() calls +# mapPf.setupMap(), so in a gunner, sentinel or launcher the whole pathfinder is +# empty - mapPf.enemyCorePos and mapPf.teamCore are permanently None. +# mapPathfinding.py already notes this for gunners (coreThreatSpots reads the +# team core from slot 7 for exactly this reason), but nothing published the +# ENEMY core, so any future non-builder logic that needs it silently does +# nothing. That is not hypothetical: a launcher throw routine written against +# mapPf.enemyCorePos never fired once in a whole game. +# Slots 0-8 are taken (0 numSpawned, 1-6 map share, 7 team core, 8 symmetry). +ENEMY_CORE_SLOT = 9 + +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 enemyCoreFromStore(self, ct: Controller): + """The enemy core for entities that have no pathfinder of their own.""" + compact = ct.read_store(ENEMY_CORE_SLOT) + if compact == 0: + return None + return Position(compact // 32, compact % 32) + + def publishEnemyCore(self, ct: Controller) -> None: + """Builders share the target so map-less entities can use it.""" + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is not None: + ct.write_store(ENEMY_CORE_SLOT, target.x * 32 + target.y) + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + self.publishEnemyCore(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_v23_fix_storemap/mapPathfinding.py b/bots/exp_v23_fix_storemap/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v23_fix_storemap/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_v23_fix_stuck/main.py b/bots/exp_v23_fix_stuck/main.py new file mode 100644 index 0000000..a5c2a2d --- /dev/null +++ b/bots/exp_v23_fix_stuck/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_v23_fix_stuck/mapPathfinding.py b/bots/exp_v23_fix_stuck/mapPathfinding.py new file mode 100644 index 0000000..21dad97 --- /dev/null +++ b/bots/exp_v23_fix_stuck/mapPathfinding.py @@ -0,0 +1,754 @@ +"""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 + # FIX 2: our own conveyors are not passable to us, so a builder whose + # only downhill step is through the home ring finds bestDir = None + # and then waits 3-10 turns (2 + id%8) before trying anything, and + # what it finally tries is a RANDOM legal direction. HANDOFF records + # this same dead-end costing aegis-v1 seven turns "2 tiles short" + # while the core died. + # Sidestep on the FIRST blocked turn instead, and pick the legal + # neighbour that actually reduces distance-to-target rather than a + # random one. Falls through to the original random walk if no + # neighbour improves, so genuinely boxed-in units still shake loose. + stepped = False + bestSideDist, bestSide = self.distMap[myLoc.x][myLoc.y], None + for i in CARDINALS: + nextPos = myLoc.add(i) + if not (0 <= nextPos.x < self.mapW and 0 <= nextPos.y < self.mapH): + continue + if not ct.can_move(i): + continue + nearbyLaunchers.sort(key=lambda lp: lp.distance_squared(nextPos)) + if nearbyLaunchers and nearbyLaunchers[0].distance_squared(nextPos) < 4: + continue + d2 = self.distMap[nextPos.x][nextPos.y] + if d2 < bestSideDist: + bestSideDist, bestSide = d2, i + if bestSide is not None: + ct.move(bestSide) + self.stuckTurns = 0 + stepped = True + if not stepped and 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_v23_fwd_armor/README.md b/bots/exp_v23_fwd_armor/README.md new file mode 100644 index 0000000..9d792a2 --- /dev/null +++ b/bots/exp_v23_fwd_armor/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_v23_fwd_armor/main.py b/bots/exp_v23_fwd_armor/main.py new file mode 100644 index 0000000..bc43c65 --- /dev/null +++ b/bots/exp_v23_fwd_armor/main.py @@ -0,0 +1,770 @@ +"""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 +# FORWARD ARMOUR - measured from the 0-5 unrated sweep by Pantheon (#1) on +# 2026-08-03: 26 barriers across 5 games, ONE HUNDRED PERCENT of them within 6 +# tiles of OUR core. They armour the siege position they have taken, not their +# own base. Result in the same games: they convert 10.2 of 13.2 guns onto our +# core (77%) while we convert 6.4 of 16.6 onto theirs (39%) - we build MORE guns +# and land fewer, because ours die on seats nobody is protecting. +# 3 Ti for 30 HP is three gunner shots absorbed, and per the team's own note our +# barriers are passable to our own units, so it costs no movement or heal access. +# This is NOT aegis-v1 (reactive armour at HOME, correctly rejected) and not +# Flotte's pre-armour (own-core seat denial). It is armour where our guns are. +FWD_ARMOR_TITANIUM_FLOOR = 100 +FWD_ARMOR_BAND = 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 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 + # No gun to place here: armour the position instead of idling. Only + # inside the enemy firing band, and only where something is shooting. + if self._forward_armor(ct, myLoc): + return True + return False + + def _forward_armor(self, ct: Controller, myLoc: Position) -> bool: + enemyCore = self.mapPf.enemyCorePos + if enemyCore is None or not ct.can_act(): + return False + if ct.get_global_resources() <= FWD_ARMOR_TITANIUM_FLOOR: + return False + if abs(myLoc.x - enemyCore.x) + abs(myLoc.y - enemyCore.y) > FWD_ARMOR_BAND: + return False + myTeam = ct.get_team() + threat = None + 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 myLoc in ct.get_attackable_tiles_from( + ct.get_position(b), ct.get_direction(b), btype): + threat = ct.get_position(b) + break + if threat is None: + return False + best = None + for d in CARDINALS: + spot = myLoc.add(d) + if ct.get_tile_building_id(spot) is not None: + continue + if not ct.can_build_barrier(spot): + continue + key = abs(spot.x - threat.x) + abs(spot.y - threat.y) + if best is None or key < best[0]: + best = (key, spot) + if best is None: + return False + ct.build_barrier(best[1]) + return True + + 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_v23_fwd_armor/mapPathfinding.py b/bots/exp_v23_fwd_armor/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v23_fwd_armor/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_v23_launchpad/README.md b/bots/exp_v23_launchpad/README.md new file mode 100644 index 0000000..9d792a2 --- /dev/null +++ b/bots/exp_v23_launchpad/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_v23_launchpad/main.py b/bots/exp_v23_launchpad/main.py new file mode 100644 index 0000000..d3c3e13 --- /dev/null +++ b/bots/exp_v23_launchpad/main.py @@ -0,0 +1,792 @@ +"""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 +# LAUNCH PAD - measured from the 0-5 unrated sweep by Pantheon (#1) on 2026-08-03. +# Their first gun lands t13; ours lands t31. The cause is in their build list: +# 5 launchers across 5 games, 100% of them within 4 tiles of THEIR OWN core, and +# 26 barriers 100% within 6 of OURS. They are not walking their builders out - +# they throw them. +# Engine facts, measured directly: 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 once. One pad next to our core therefore saves ~7 turns for EVERY +# outbound builder for the rest of the game. +# (An earlier attempt built a 4-launcher CHAIN and taxed the siegers to lay it - +# 156 Ti and it scored 33%. Pantheon builds ONE, at home, paid for by a builder +# that was standing there anyway.) +LAUNCHPAD_MAX = 1 +LAUNCHPAD_TITANIUM_FLOOR = 120 +LAUNCH_MIN_GAIN = 3 + +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) + elif etype == EntityType.LAUNCHER: + self.runLauncher(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 runLauncher(self, ct: Controller) -> None: + """Throw any friendly builder standing next to us toward their core.""" + target = self.mapPf.enemyCorePos + if target is None: + return + me = ct.get_position() + myTeam = 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) != myTeam + 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, bestD = None, here - LAUNCH_MIN_GAIN + for t in tiles: + d = abs(t.x - target.x) + abs(t.y - target.y) + if d < bestD and ct.can_launch(bot, t): + best, bestD = t, d + if best is not None: + ct.launch(bot, best) + return + + def _build_launchpad(self, ct: Controller, myLoc: Position) -> bool: + """One launcher beside our core, built by a defender already standing there.""" + teamCore = self.mapPf.teamCore + if teamCore is None or self.mapPf.enemyCorePos is None: + return False + if not ct.can_act() or ct.get_global_resources() <= LAUNCHPAD_TITANIUM_FLOOR: + return False + myTeam = ct.get_team() + pads = 0 + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + pads += 1 + if pads >= LAUNCHPAD_MAX: + return False + if abs(myLoc.x - teamCore.x) + abs(myLoc.y - teamCore.y) > 4: + return False + # prefer a tile on the enemy side so the throw arc points outward + best = None + for d in CARDINALS: + spot = myLoc.add(d) + if not ct.can_build_launcher(spot): + continue + key = abs(spot.x - self.mapPf.enemyCorePos.x) + abs(spot.y - self.mapPf.enemyCorePos.y) + if best is None or key < best[0]: + best = (key, spot) + if best is None: + return False + ct.build_launcher(best[1]) + return True + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + if self._build_launchpad(ct, myLoc): + 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() + 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_v23_launchpad/mapPathfinding.py b/bots/exp_v23_launchpad/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v23_launchpad/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_v23_launchpad10/README.md b/bots/exp_v23_launchpad10/README.md new file mode 100644 index 0000000..9d792a2 --- /dev/null +++ b/bots/exp_v23_launchpad10/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_v23_launchpad10/main.py b/bots/exp_v23_launchpad10/main.py new file mode 100644 index 0000000..5bf871b --- /dev/null +++ b/bots/exp_v23_launchpad10/main.py @@ -0,0 +1,903 @@ +"""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 +# LAUNCH PAD, done Pantheon's way. Traced from their 0-5 sweep of our v22: +# launcher built at t1, 3-4 tiles from their own core +# t2 builder (3,4)->(4,10) d7 t3 (2,4)->(4,10) d8 +# t4 (4,4)->(1,6) d5 t5 (4,15)->(1,17) d5 +# They build the pad on TURN ONE and throw every newly spawned builder 7-8 tiles +# on the 1-turn cooldown, several to the same staging tile. Their first gun +# lands t13; ours lands t22-31 because our builders walk. +# Two earlier attempts failed for implementation reasons, not because the +# mechanic is wrong: v1 gated the pad behind `resources > 120` AND the defender +# role so it went up late, and placed it 1-2 tiles from the core instead of the +# 3-4 where spawning builders actually stand. v2 added routing and still sat at +# t22 - and accidentally built 6 pads, proving pad COUNT was never the issue. +# Timing and placement were. +LAUNCHPAD_ROUND_LIMIT = 8 # pad goes up immediately or not at all +LAUNCHPAD_MIN_D = 2 # far enough out that spawned builders reach it +LAUNCHPAD_MAX_D = 4 +LAUNCH_MIN_GAIN = 3 +# v3 got the pad right - one, at t1, 2-4 tiles out - and still launched ZERO +# times. Cause: every entity gets its own Player, and LAUNCHERS NEVER CALL +# setupMap(), so a launcher's mapPf.enemyCorePos is permanently None and the +# throw routine returned immediately. (mapPathfinding.py notes the same trap for +# gunners.) Builders do have the map, so they publish the target here and the +# launcher reads it. Slots 0-8 are taken: 0 numSpawned, 1-6 map share, 7 team +# core, 8 symmetry mask. +ENEMY_CORE_SLOT = 9 +# v4 launched 8.5 times a game starting t2 - mechanically identical to Pantheon - +# and first gun still went t24 -> t26. The pad throws ANY builder in pickup +# range, and in the opening that is mostly the economy crew, so we were flinging +# harvesters and routers across the map. Attackers now walk onto the pad +# deliberately and the launcher only throws a builder that is standing on the +# claim tile, so economy units working near the core are never picked up. +LAUNCH_CLAIM_SLOT = 10 +# v5 launched 17.1x/game (Pantheon: 3-4) and gated 40%: attackers drifted back +# inside the claim radius and rode again and again, and the round trip cost more +# than the 7 tiles bought. v6 tried a one-ride-per-builder flag and collapsed to +# 0.8 launches, because the flag was set when a builder CLAIMED and only one +# claim per turn survives the single slot - everyone who lost the race marked +# itself done and never retried. +# The geometry does the job without a flag: only throw to a tile that lands the +# builder OUTSIDE the claim radius. A thrown builder is then never eligible +# again, so each rides once, and nobody is disqualified by losing a race. +# Slot 7 already holds our own core, which is what a map-less launcher needs. +TEAM_CORE_SLOT = 7 +LAUNCH_MIN_EXIT = 7 +# THE BOLT. Pantheon's thrown builder never touches the pad again: t3:14, then +# 13,12,11,10,9,8,7,6,5 - one tile a turn, straight in, first gun t13. +# Ours re-entered the ride logic every time it drifted back inside the radius, +# so v5 rode 17x/game (gate 40%), v7 8.8x (gate 38%), v8 deadlocked at the pad +# for 80 turns and v9 oscillated 21<->22 all game. +# The ride is therefore a ONE-SHOT IN THE OPENING, not a state: a builder may +# try for the pad only in its first few rounds of life, and once it stops trying +# - thrown, timed out, whatever - it marches and never looks back. +LAUNCH_RIDE_WINDOW = 10 # rounds of a builder's life it may seek the pad +LAUNCH_WAIT_LIMIT = 2 # must land at least this far from our own core + +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.rideDone = False + self.rideWait = 0 + self.myAge = 0 + 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) + elif etype == EntityType.LAUNCHER: + self.runLauncher(ct) + + def _publish_enemy_core(self, ct: Controller) -> None: + """Share the target so map-less entities (launchers) can use it.""" + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return + ct.write_store(ENEMY_CORE_SLOT, target.x * 32 + target.y) + + def runLauncher(self, ct: Controller) -> None: + """Throw any friendly builder in pickup range toward their core. + + Reads the target from the store: a launcher has no map of its own. + """ + compact = ct.read_store(ENEMY_CORE_SLOT) + if compact == 0: + return + target = Position(compact // 32, compact % 32) + me = ct.get_position() + myTeam = ct.get_team() + tiles = ct.get_attackable_tiles_from(me, Direction.NORTH, EntityType.LAUNCHER) + if not tiles: + return + claim = ct.read_store(LAUNCH_CLAIM_SLOT) + if claim == 0: + return # nobody is asking for a ride this turn + want = Position(claim // 32, claim % 32) + for uid in ct.get_nearby_units(): + if (ct.get_team(uid) != myTeam + or ct.get_entity_type(uid) != EntityType.BUILDER_BOT): + continue + bot = ct.get_position(uid) + if bot.x != want.x or bot.y != want.y: + continue # only the builder that claimed the ride + here = abs(bot.x - target.x) + abs(bot.y - target.y) + homeCompact = ct.read_store(TEAM_CORE_SLOT) + home = (Position(homeCompact // 32, homeCompact % 32) + if homeCompact else None) + best, bestD = None, here - LAUNCH_MIN_GAIN + for t in tiles: + if home is not None: + if abs(t.x - home.x) + abs(t.y - home.y) < LAUNCH_MIN_EXIT: + continue # too short: they would ride again + d = abs(t.x - target.x) + abs(t.y - target.y) + if d < bestD and ct.can_launch(bot, t): + best, bestD = t, d + if best is not None: + ct.launch(bot, best) + return + + def _build_launchpad(self, ct: Controller, myLoc: Position) -> bool: + """Turn-one pad, 2-4 tiles out on the enemy side. Any builder, no role.""" + if ct.get_current_round() > LAUNCHPAD_ROUND_LIMIT or not ct.can_act(): + return False + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return False + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + return False # one is enough; vision covers the home area + best = None + for d in CARDINALS: + spot = myLoc.add(d) + dc = abs(spot.x - teamCore.x) + abs(spot.y - teamCore.y) + if not (LAUNCHPAD_MIN_D <= dc <= LAUNCHPAD_MAX_D): + continue + if not ct.can_build_launcher(spot): + continue + key = abs(spot.x - target.x) + abs(spot.y - target.y) + if best is None or key < best[0]: + best = (key, spot) + if best is None: + return False + ct.build_launcher(best[1]) + return True + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + self._publish_enemy_core(ct) + if self._build_launchpad(ct, myLoc): + return + 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 _claim_ride(self, ct: Controller, myLoc: Position) -> bool: + """One shot at the pad in the opening, then bolt and never come back.""" + self.myAge += 1 + if self.rideDone or self.myAge > LAUNCH_RIDE_WINDOW: + return False + teamCore = self.mapPf.teamCore + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if teamCore is None or target is None: + return False + if abs(myLoc.x - teamCore.x) + abs(myLoc.y - teamCore.y) > 6: + return False # already out on the map; keep marching + myTeam = ct.get_team() + pad = None + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + pad = ct.get_position(b) + break + if pad is None: + return False + d = abs(myLoc.x - pad.x) + abs(myLoc.y - pad.y) + if d > 2: + self.mapPf.moveTo(ct, pad) + return True + self.rideWait += 1 + if self.rideWait > LAUNCH_WAIT_LIMIT: + self.rideDone = True # pad is busy - march, permanently + return False + ct.write_store(LAUNCH_CLAIM_SLOT, myLoc.x * 32 + myLoc.y) + return True # stand still this turn and get thrown + + 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 self._claim_ride(ct, myLoc): + return + 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_v23_launchpad10/mapPathfinding.py b/bots/exp_v23_launchpad10/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v23_launchpad10/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_v23_launchpad11/README.md b/bots/exp_v23_launchpad11/README.md new file mode 100644 index 0000000..9d792a2 --- /dev/null +++ b/bots/exp_v23_launchpad11/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_v23_launchpad11/main.py b/bots/exp_v23_launchpad11/main.py new file mode 100644 index 0000000..85f63f8 --- /dev/null +++ b/bots/exp_v23_launchpad11/main.py @@ -0,0 +1,910 @@ +"""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 +# LAUNCH PAD, done Pantheon's way. Traced from their 0-5 sweep of our v22: +# launcher built at t1, 3-4 tiles from their own core +# t2 builder (3,4)->(4,10) d7 t3 (2,4)->(4,10) d8 +# t4 (4,4)->(1,6) d5 t5 (4,15)->(1,17) d5 +# They build the pad on TURN ONE and throw every newly spawned builder 7-8 tiles +# on the 1-turn cooldown, several to the same staging tile. Their first gun +# lands t13; ours lands t22-31 because our builders walk. +# Two earlier attempts failed for implementation reasons, not because the +# mechanic is wrong: v1 gated the pad behind `resources > 120` AND the defender +# role so it went up late, and placed it 1-2 tiles from the core instead of the +# 3-4 where spawning builders actually stand. v2 added routing and still sat at +# t22 - and accidentally built 6 pads, proving pad COUNT was never the issue. +# Timing and placement were. +LAUNCHPAD_ROUND_LIMIT = 8 # pad goes up immediately or not at all +LAUNCHPAD_MIN_D = 2 # far enough out that spawned builders reach it +LAUNCHPAD_MAX_D = 4 +LAUNCH_MIN_GAIN = 3 +# v3 got the pad right - one, at t1, 2-4 tiles out - and still launched ZERO +# times. Cause: every entity gets its own Player, and LAUNCHERS NEVER CALL +# setupMap(), so a launcher's mapPf.enemyCorePos is permanently None and the +# throw routine returned immediately. (mapPathfinding.py notes the same trap for +# gunners.) Builders do have the map, so they publish the target here and the +# launcher reads it. Slots 0-8 are taken: 0 numSpawned, 1-6 map share, 7 team +# core, 8 symmetry mask. +ENEMY_CORE_SLOT = 9 +# v4 launched 8.5 times a game starting t2 - mechanically identical to Pantheon - +# and first gun still went t24 -> t26. The pad throws ANY builder in pickup +# range, and in the opening that is mostly the economy crew, so we were flinging +# harvesters and routers across the map. Attackers now walk onto the pad +# deliberately and the launcher only throws a builder that is standing on the +# claim tile, so economy units working near the core are never picked up. +LAUNCH_CLAIM_SLOT = 10 +# v5 launched 17.1x/game (Pantheon: 3-4) and gated 40%: attackers drifted back +# inside the claim radius and rode again and again, and the round trip cost more +# than the 7 tiles bought. v6 tried a one-ride-per-builder flag and collapsed to +# 0.8 launches, because the flag was set when a builder CLAIMED and only one +# claim per turn survives the single slot - everyone who lost the race marked +# itself done and never retried. +# The geometry does the job without a flag: only throw to a tile that lands the +# builder OUTSIDE the claim radius. A thrown builder is then never eligible +# again, so each rides once, and nobody is disqualified by losing a race. +# Slot 7 already holds our own core, which is what a map-less launcher needs. +TEAM_CORE_SLOT = 7 +LAUNCH_MIN_EXIT = 7 +# THE BOLT. Pantheon's thrown builder never touches the pad again: t3:14, then +# 13,12,11,10,9,8,7,6,5 - one tile a turn, straight in, first gun t13. +# Ours re-entered the ride logic every time it drifted back inside the radius, +# so v5 rode 17x/game (gate 40%), v7 8.8x (gate 38%), v8 deadlocked at the pad +# for 80 turns and v9 oscillated 21<->22 all game. +# The ride is therefore a ONE-SHOT IN THE OPENING, not a state: a builder may +# try for the pad only in its first few rounds of life, and once it stops trying +# - thrown, timed out, whatever - it marches and never looks back. +# NO DETOUR. v10 traced b3 bouncing 22<->21<->22 for ten turns without ever +# reaching the pad: mapPf.moveTo dead-ends against our OWN conveyor ring (our +# conveyors are not passable to us - HANDOFF records the same bug costing aegis +# 7 turns "2 tiles short"). And Pantheon never walks to their pad anyway: their +# builders are thrown from (3,4), (2,4), (4,4) with their core at (2,2) - they +# SPAWN in pickup range. So a rider must never divert; it claims only if it is +# already beside the pad on its way out, otherwise it marches. +LAUNCH_RIDE_WINDOW = 10 # rounds of a builder's life it may seek the pad +LAUNCH_WAIT_LIMIT = 2 # must land at least this far from our own core + +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.rideDone = False + self.rideWait = 0 + self.myAge = 0 + 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) + elif etype == EntityType.LAUNCHER: + self.runLauncher(ct) + + def _publish_enemy_core(self, ct: Controller) -> None: + """Share the target so map-less entities (launchers) can use it.""" + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return + ct.write_store(ENEMY_CORE_SLOT, target.x * 32 + target.y) + + def runLauncher(self, ct: Controller) -> None: + """Throw any friendly builder in pickup range toward their core. + + Reads the target from the store: a launcher has no map of its own. + """ + compact = ct.read_store(ENEMY_CORE_SLOT) + if compact == 0: + return + target = Position(compact // 32, compact % 32) + me = ct.get_position() + myTeam = ct.get_team() + tiles = ct.get_attackable_tiles_from(me, Direction.NORTH, EntityType.LAUNCHER) + if not tiles: + return + claim = ct.read_store(LAUNCH_CLAIM_SLOT) + if claim == 0: + return # nobody is asking for a ride this turn + want = Position(claim // 32, claim % 32) + for uid in ct.get_nearby_units(): + if (ct.get_team(uid) != myTeam + or ct.get_entity_type(uid) != EntityType.BUILDER_BOT): + continue + bot = ct.get_position(uid) + if bot.x != want.x or bot.y != want.y: + continue # only the builder that claimed the ride + here = abs(bot.x - target.x) + abs(bot.y - target.y) + homeCompact = ct.read_store(TEAM_CORE_SLOT) + home = (Position(homeCompact // 32, homeCompact % 32) + if homeCompact else None) + best, bestD = None, here - LAUNCH_MIN_GAIN + for t in tiles: + if home is not None: + if abs(t.x - home.x) + abs(t.y - home.y) < LAUNCH_MIN_EXIT: + continue # too short: they would ride again + d = abs(t.x - target.x) + abs(t.y - target.y) + if d < bestD and ct.can_launch(bot, t): + best, bestD = t, d + if best is not None: + ct.launch(bot, best) + return + + def _build_launchpad(self, ct: Controller, myLoc: Position) -> bool: + """Turn-one pad, 2-4 tiles out on the enemy side. Any builder, no role.""" + if ct.get_current_round() > LAUNCHPAD_ROUND_LIMIT or not ct.can_act(): + return False + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return False + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + return False # one is enough; vision covers the home area + best = None + for d in CARDINALS: + spot = myLoc.add(d) + dc = abs(spot.x - teamCore.x) + abs(spot.y - teamCore.y) + if not (LAUNCHPAD_MIN_D <= dc <= LAUNCHPAD_MAX_D): + continue + if not ct.can_build_launcher(spot): + continue + key = abs(spot.x - target.x) + abs(spot.y - target.y) + if best is None or key < best[0]: + best = (key, spot) + if best is None: + return False + ct.build_launcher(best[1]) + return True + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + self._publish_enemy_core(ct) + if self._build_launchpad(ct, myLoc): + return + 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 _claim_ride(self, ct: Controller, myLoc: Position) -> bool: + """One shot at the pad in the opening, then bolt and never come back.""" + self.myAge += 1 + if self.rideDone or self.myAge > LAUNCH_RIDE_WINDOW: + return False + teamCore = self.mapPf.teamCore + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if teamCore is None or target is None: + return False + if abs(myLoc.x - teamCore.x) + abs(myLoc.y - teamCore.y) > 6: + return False # already out on the map; keep marching + myTeam = ct.get_team() + pad = None + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + pad = ct.get_position(b) + break + if pad is None: + return False + d = abs(myLoc.x - pad.x) + abs(myLoc.y - pad.y) + if d > 2: + self.rideDone = True # not beside it - march, never detour + return False + self.rideWait += 1 + if self.rideWait > LAUNCH_WAIT_LIMIT: + self.rideDone = True # pad is busy - march, permanently + return False + ct.write_store(LAUNCH_CLAIM_SLOT, myLoc.x * 32 + myLoc.y) + return True # stand still this turn and get thrown + + 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 self._claim_ride(ct, myLoc): + return + 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_v23_launchpad11/mapPathfinding.py b/bots/exp_v23_launchpad11/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v23_launchpad11/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_v23_launchpad12/README.md b/bots/exp_v23_launchpad12/README.md new file mode 100644 index 0000000..9d792a2 --- /dev/null +++ b/bots/exp_v23_launchpad12/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_v23_launchpad12/main.py b/bots/exp_v23_launchpad12/main.py new file mode 100644 index 0000000..123f283 --- /dev/null +++ b/bots/exp_v23_launchpad12/main.py @@ -0,0 +1,920 @@ +"""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 +# LAUNCH PAD, done Pantheon's way. Traced from their 0-5 sweep of our v22: +# launcher built at t1, 3-4 tiles from their own core +# t2 builder (3,4)->(4,10) d7 t3 (2,4)->(4,10) d8 +# t4 (4,4)->(1,6) d5 t5 (4,15)->(1,17) d5 +# They build the pad on TURN ONE and throw every newly spawned builder 7-8 tiles +# on the 1-turn cooldown, several to the same staging tile. Their first gun +# lands t13; ours lands t22-31 because our builders walk. +# Two earlier attempts failed for implementation reasons, not because the +# mechanic is wrong: v1 gated the pad behind `resources > 120` AND the defender +# role so it went up late, and placed it 1-2 tiles from the core instead of the +# 3-4 where spawning builders actually stand. v2 added routing and still sat at +# t22 - and accidentally built 6 pads, proving pad COUNT was never the issue. +# Timing and placement were. +LAUNCHPAD_ROUND_LIMIT = 8 # pad goes up immediately or not at all +LAUNCHPAD_MIN_D = 2 # far enough out that spawned builders reach it +LAUNCHPAD_MAX_D = 4 +LAUNCH_MIN_GAIN = 3 +# v3 got the pad right - one, at t1, 2-4 tiles out - and still launched ZERO +# times. Cause: every entity gets its own Player, and LAUNCHERS NEVER CALL +# setupMap(), so a launcher's mapPf.enemyCorePos is permanently None and the +# throw routine returned immediately. (mapPathfinding.py notes the same trap for +# gunners.) Builders do have the map, so they publish the target here and the +# launcher reads it. Slots 0-8 are taken: 0 numSpawned, 1-6 map share, 7 team +# core, 8 symmetry mask. +ENEMY_CORE_SLOT = 9 +# v4 launched 8.5 times a game starting t2 - mechanically identical to Pantheon - +# and first gun still went t24 -> t26. The pad throws ANY builder in pickup +# range, and in the opening that is mostly the economy crew, so we were flinging +# harvesters and routers across the map. Attackers now walk onto the pad +# deliberately and the launcher only throws a builder that is standing on the +# claim tile, so economy units working near the core are never picked up. +LAUNCH_CLAIM_SLOT = 10 +# v5 launched 17.1x/game (Pantheon: 3-4) and gated 40%: attackers drifted back +# inside the claim radius and rode again and again, and the round trip cost more +# than the 7 tiles bought. v6 tried a one-ride-per-builder flag and collapsed to +# 0.8 launches, because the flag was set when a builder CLAIMED and only one +# claim per turn survives the single slot - everyone who lost the race marked +# itself done and never retried. +# The geometry does the job without a flag: only throw to a tile that lands the +# builder OUTSIDE the claim radius. A thrown builder is then never eligible +# again, so each rides once, and nobody is disqualified by losing a race. +# Slot 7 already holds our own core, which is what a map-less launcher needs. +TEAM_CORE_SLOT = 7 +LAUNCH_MIN_EXIT = 7 +# THE BOLT. Pantheon's thrown builder never touches the pad again: t3:14, then +# 13,12,11,10,9,8,7,6,5 - one tile a turn, straight in, first gun t13. +# Ours re-entered the ride logic every time it drifted back inside the radius, +# so v5 rode 17x/game (gate 40%), v7 8.8x (gate 38%), v8 deadlocked at the pad +# for 80 turns and v9 oscillated 21<->22 all game. +# The ride is therefore a ONE-SHOT IN THE OPENING, not a state: a builder may +# try for the pad only in its first few rounds of life, and once it stops trying +# - thrown, timed out, whatever - it marches and never looks back. +# NO DETOUR. v10 traced b3 bouncing 22<->21<->22 for ten turns without ever +# reaching the pad: mapPf.moveTo dead-ends against our OWN conveyor ring (our +# conveyors are not passable to us - HANDOFF records the same bug costing aegis +# 7 turns "2 tiles short"). And Pantheon never walks to their pad anyway: their +# builders are thrown from (3,4), (2,4), (4,4) with their core at (2,2) - they +# SPAWN in pickup range. So a rider must never divert; it claims only if it is +# already beside the pad on its way out, otherwise it marches. +LAUNCH_RIDE_WINDOW = 10 # rounds of a builder's life it may seek the pad +LAUNCH_WAIT_LIMIT = 2 # must land at least this far from our own core + +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.rideDone = False + self.rideWait = 0 + self.myAge = 0 + 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) + elif etype == EntityType.LAUNCHER: + self.runLauncher(ct) + + def _publish_enemy_core(self, ct: Controller) -> None: + """Share the target so map-less entities (launchers) can use it.""" + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return + ct.write_store(ENEMY_CORE_SLOT, target.x * 32 + target.y) + + def runLauncher(self, ct: Controller) -> None: + """Throw any friendly builder in pickup range toward their core. + + Reads the target from the store: a launcher has no map of its own. + """ + compact = ct.read_store(ENEMY_CORE_SLOT) + if compact == 0: + return + target = Position(compact // 32, compact % 32) + me = ct.get_position() + myTeam = ct.get_team() + tiles = ct.get_attackable_tiles_from(me, Direction.NORTH, EntityType.LAUNCHER) + if not tiles: + return + claim = ct.read_store(LAUNCH_CLAIM_SLOT) + if claim == 0: + return # nobody is asking for a ride this turn + want = Position(claim // 32, claim % 32) + for uid in ct.get_nearby_units(): + if (ct.get_team(uid) != myTeam + or ct.get_entity_type(uid) != EntityType.BUILDER_BOT): + continue + bot = ct.get_position(uid) + if bot.x != want.x or bot.y != want.y: + continue # only the builder that claimed the ride + here = abs(bot.x - target.x) + abs(bot.y - target.y) + homeCompact = ct.read_store(TEAM_CORE_SLOT) + home = (Position(homeCompact // 32, homeCompact % 32) + if homeCompact else None) + best, bestD = None, here - LAUNCH_MIN_GAIN + for t in tiles: + if home is not None: + if abs(t.x - home.x) + abs(t.y - home.y) < LAUNCH_MIN_EXIT: + continue # too short: they would ride again + d = abs(t.x - target.x) + abs(t.y - target.y) + if d < bestD and ct.can_launch(bot, t): + best, bestD = t, d + if best is not None: + ct.launch(bot, best) + return + + def _build_launchpad(self, ct: Controller, myLoc: Position) -> bool: + """Turn-one pad, 2-4 tiles out on the enemy side. Any builder, no role.""" + if ct.get_current_round() > LAUNCHPAD_ROUND_LIMIT or not ct.can_act(): + return False + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return False + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + return False # one is enough; vision covers the home area + # DO NOT BLOCK THE DOOR. Picking the tile closest to their core puts the + # pad squarely on the lane every builder walks out through, and our own + # buildings block our own units - traced as b3 oscillating 22<->21 for + # eight turns in v11 where the pad-less parent marched straight out. + # Prefer a tile beside the exit lane: still in pickup range of departing + # builders, never standing in their way. + axis_x = 1 if abs(target.x - teamCore.x) >= abs(target.y - teamCore.y) else 0 + best = None + for d in CARDINALS: + spot = myLoc.add(d) + dc = abs(spot.x - teamCore.x) + abs(spot.y - teamCore.y) + if not (LAUNCHPAD_MIN_D <= dc <= LAUNCHPAD_MAX_D): + continue + if not ct.can_build_launcher(spot): + continue + # offset perpendicular to the core->enemy axis + perp = abs(spot.y - teamCore.y) if axis_x else abs(spot.x - teamCore.x) + key = (0 if perp >= 1 else 1, + abs(spot.x - target.x) + abs(spot.y - target.y)) + if best is None or key < best[0]: + best = (key, spot) + if best is None: + return False + ct.build_launcher(best[1]) + return True + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + self._publish_enemy_core(ct) + if self._build_launchpad(ct, myLoc): + return + 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 _claim_ride(self, ct: Controller, myLoc: Position) -> bool: + """One shot at the pad in the opening, then bolt and never come back.""" + self.myAge += 1 + if self.rideDone or self.myAge > LAUNCH_RIDE_WINDOW: + return False + teamCore = self.mapPf.teamCore + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if teamCore is None or target is None: + return False + if abs(myLoc.x - teamCore.x) + abs(myLoc.y - teamCore.y) > 6: + return False # already out on the map; keep marching + myTeam = ct.get_team() + pad = None + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + pad = ct.get_position(b) + break + if pad is None: + return False + d = abs(myLoc.x - pad.x) + abs(myLoc.y - pad.y) + if d > 2: + self.rideDone = True # not beside it - march, never detour + return False + self.rideWait += 1 + if self.rideWait > LAUNCH_WAIT_LIMIT: + self.rideDone = True # pad is busy - march, permanently + return False + ct.write_store(LAUNCH_CLAIM_SLOT, myLoc.x * 32 + myLoc.y) + return True # stand still this turn and get thrown + + 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 self._claim_ride(ct, myLoc): + return + 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_v23_launchpad12/mapPathfinding.py b/bots/exp_v23_launchpad12/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v23_launchpad12/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_v23_launchpad2/README.md b/bots/exp_v23_launchpad2/README.md new file mode 100644 index 0000000..9d792a2 --- /dev/null +++ b/bots/exp_v23_launchpad2/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_v23_launchpad2/main.py b/bots/exp_v23_launchpad2/main.py new file mode 100644 index 0000000..35b36a0 --- /dev/null +++ b/bots/exp_v23_launchpad2/main.py @@ -0,0 +1,826 @@ +"""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 +# LAUNCH PAD - measured from the 0-5 unrated sweep by Pantheon (#1) on 2026-08-03. +# Their first gun lands t13; ours lands t31. The cause is in their build list: +# 5 launchers across 5 games, 100% of them within 4 tiles of THEIR OWN core, and +# 26 barriers 100% within 6 of OURS. They are not walking their builders out - +# they throw them. +# Engine facts, measured directly: 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 once. One pad next to our core therefore saves ~7 turns for EVERY +# outbound builder for the rest of the game. +# (An earlier attempt built a 4-launcher CHAIN and taxed the siegers to lay it - +# 156 Ti and it scored 33%. Pantheon builds ONE, at home, paid for by a builder +# that was standing there anyway.) +# v1 built the pad but only moved the first gun t24 -> t22, because outbound +# attackers path straight at the enemy core and rarely pass within the launcher's +# 1-2 tile pickup range. Pantheon reaches t13 with the SAME single pad, so the +# missing piece is routing: an attacker that is still near home detours one step +# to the pad, gets thrown ~7 tiles, and walks from there. +LAUNCHPAD_RIDE_RADIUS = 7 # only detour while still this close to our own core +LAUNCHPAD_MAX = 1 +LAUNCHPAD_TITANIUM_FLOOR = 120 +LAUNCH_MIN_GAIN = 3 + +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) + elif etype == EntityType.LAUNCHER: + self.runLauncher(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 _ride_launchpad(self, ct: Controller, myLoc: Position) -> bool: + """Walk one step onto the pad instead of the whole way to their core.""" + teamCore = self.mapPf.teamCore + enemyCore = self.mapPf.enemyCorePos + if teamCore is None or enemyCore is None: + return False + # only while still at home; once out on the map, keep marching + if abs(myLoc.x - teamCore.x) + abs(myLoc.y - teamCore.y) > LAUNCHPAD_RIDE_RADIUS: + return False + myTeam = ct.get_team() + pad = None + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + pad = ct.get_position(b) + break + if pad is None: + return False + d = abs(myLoc.x - pad.x) + abs(myLoc.y - pad.y) + if d <= 2: + return False # already in pickup range - let the pad throw us + if d > LAUNCHPAD_RIDE_RADIUS: + return False + self.mapPf.moveTo(ct, pad) + return True + + 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 self._ride_launchpad(ct, myLoc): + return + 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 runLauncher(self, ct: Controller) -> None: + """Throw any friendly builder standing next to us toward their core.""" + target = self.mapPf.enemyCorePos + if target is None: + return + me = ct.get_position() + myTeam = 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) != myTeam + 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, bestD = None, here - LAUNCH_MIN_GAIN + for t in tiles: + d = abs(t.x - target.x) + abs(t.y - target.y) + if d < bestD and ct.can_launch(bot, t): + best, bestD = t, d + if best is not None: + ct.launch(bot, best) + return + + def _build_launchpad(self, ct: Controller, myLoc: Position) -> bool: + """One launcher beside our core, built by a defender already standing there.""" + teamCore = self.mapPf.teamCore + if teamCore is None or self.mapPf.enemyCorePos is None: + return False + if not ct.can_act() or ct.get_global_resources() <= LAUNCHPAD_TITANIUM_FLOOR: + return False + myTeam = ct.get_team() + pads = 0 + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + pads += 1 + if pads >= LAUNCHPAD_MAX: + return False + if abs(myLoc.x - teamCore.x) + abs(myLoc.y - teamCore.y) > 4: + return False + # prefer a tile on the enemy side so the throw arc points outward + best = None + for d in CARDINALS: + spot = myLoc.add(d) + if not ct.can_build_launcher(spot): + continue + key = abs(spot.x - self.mapPf.enemyCorePos.x) + abs(spot.y - self.mapPf.enemyCorePos.y) + if best is None or key < best[0]: + best = (key, spot) + if best is None: + return False + ct.build_launcher(best[1]) + return True + + def runDefend(self, ct: Controller): + myLoc = ct.get_position() + myTeam = ct.get_team() + if self._build_launchpad(ct, myLoc): + 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() + 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_v23_launchpad2/mapPathfinding.py b/bots/exp_v23_launchpad2/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v23_launchpad2/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_v23_launchpad3/README.md b/bots/exp_v23_launchpad3/README.md new file mode 100644 index 0000000..9d792a2 --- /dev/null +++ b/bots/exp_v23_launchpad3/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_v23_launchpad3/main.py b/bots/exp_v23_launchpad3/main.py new file mode 100644 index 0000000..bff4d67 --- /dev/null +++ b/bots/exp_v23_launchpad3/main.py @@ -0,0 +1,802 @@ +"""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 +# LAUNCH PAD, done Pantheon's way. Traced from their 0-5 sweep of our v22: +# launcher built at t1, 3-4 tiles from their own core +# t2 builder (3,4)->(4,10) d7 t3 (2,4)->(4,10) d8 +# t4 (4,4)->(1,6) d5 t5 (4,15)->(1,17) d5 +# They build the pad on TURN ONE and throw every newly spawned builder 7-8 tiles +# on the 1-turn cooldown, several to the same staging tile. Their first gun +# lands t13; ours lands t22-31 because our builders walk. +# Two earlier attempts failed for implementation reasons, not because the +# mechanic is wrong: v1 gated the pad behind `resources > 120` AND the defender +# role so it went up late, and placed it 1-2 tiles from the core instead of the +# 3-4 where spawning builders actually stand. v2 added routing and still sat at +# t22 - and accidentally built 6 pads, proving pad COUNT was never the issue. +# Timing and placement were. +LAUNCHPAD_ROUND_LIMIT = 8 # pad goes up immediately or not at all +LAUNCHPAD_MIN_D = 2 # far enough out that spawned builders reach it +LAUNCHPAD_MAX_D = 4 +LAUNCH_MIN_GAIN = 3 + +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) + elif etype == EntityType.LAUNCHER: + self.runLauncher(ct) + + def runLauncher(self, ct: Controller) -> None: + """Throw any friendly builder in pickup range toward their core.""" + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return + me = ct.get_position() + myTeam = 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) != myTeam + 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, bestD = None, here - LAUNCH_MIN_GAIN + for t in tiles: + d = abs(t.x - target.x) + abs(t.y - target.y) + if d < bestD and ct.can_launch(bot, t): + best, bestD = t, d + if best is not None: + ct.launch(bot, best) + return + + def _build_launchpad(self, ct: Controller, myLoc: Position) -> bool: + """Turn-one pad, 2-4 tiles out on the enemy side. Any builder, no role.""" + if ct.get_current_round() > LAUNCHPAD_ROUND_LIMIT or not ct.can_act(): + return False + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return False + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + return False # one is enough; vision covers the home area + best = None + for d in CARDINALS: + spot = myLoc.add(d) + dc = abs(spot.x - teamCore.x) + abs(spot.y - teamCore.y) + if not (LAUNCHPAD_MIN_D <= dc <= LAUNCHPAD_MAX_D): + continue + if not ct.can_build_launcher(spot): + continue + key = abs(spot.x - target.x) + abs(spot.y - target.y) + if best is None or key < best[0]: + best = (key, spot) + if best is None: + return False + ct.build_launcher(best[1]) + return True + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self._build_launchpad(ct, myLoc): + return + 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_v23_launchpad3/mapPathfinding.py b/bots/exp_v23_launchpad3/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v23_launchpad3/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_v23_launchpad4/README.md b/bots/exp_v23_launchpad4/README.md new file mode 100644 index 0000000..9d792a2 --- /dev/null +++ b/bots/exp_v23_launchpad4/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_v23_launchpad4/main.py b/bots/exp_v23_launchpad4/main.py new file mode 100644 index 0000000..39f72c0 --- /dev/null +++ b/bots/exp_v23_launchpad4/main.py @@ -0,0 +1,822 @@ +"""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 +# LAUNCH PAD, done Pantheon's way. Traced from their 0-5 sweep of our v22: +# launcher built at t1, 3-4 tiles from their own core +# t2 builder (3,4)->(4,10) d7 t3 (2,4)->(4,10) d8 +# t4 (4,4)->(1,6) d5 t5 (4,15)->(1,17) d5 +# They build the pad on TURN ONE and throw every newly spawned builder 7-8 tiles +# on the 1-turn cooldown, several to the same staging tile. Their first gun +# lands t13; ours lands t22-31 because our builders walk. +# Two earlier attempts failed for implementation reasons, not because the +# mechanic is wrong: v1 gated the pad behind `resources > 120` AND the defender +# role so it went up late, and placed it 1-2 tiles from the core instead of the +# 3-4 where spawning builders actually stand. v2 added routing and still sat at +# t22 - and accidentally built 6 pads, proving pad COUNT was never the issue. +# Timing and placement were. +LAUNCHPAD_ROUND_LIMIT = 8 # pad goes up immediately or not at all +LAUNCHPAD_MIN_D = 2 # far enough out that spawned builders reach it +LAUNCHPAD_MAX_D = 4 +LAUNCH_MIN_GAIN = 3 +# v3 got the pad right - one, at t1, 2-4 tiles out - and still launched ZERO +# times. Cause: every entity gets its own Player, and LAUNCHERS NEVER CALL +# setupMap(), so a launcher's mapPf.enemyCorePos is permanently None and the +# throw routine returned immediately. (mapPathfinding.py notes the same trap for +# gunners.) Builders do have the map, so they publish the target here and the +# launcher reads it. Slots 0-8 are taken: 0 numSpawned, 1-6 map share, 7 team +# core, 8 symmetry mask. +ENEMY_CORE_SLOT = 9 + +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) + elif etype == EntityType.LAUNCHER: + self.runLauncher(ct) + + def _publish_enemy_core(self, ct: Controller) -> None: + """Share the target so map-less entities (launchers) can use it.""" + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return + ct.write_store(ENEMY_CORE_SLOT, target.x * 32 + target.y) + + def runLauncher(self, ct: Controller) -> None: + """Throw any friendly builder in pickup range toward their core. + + Reads the target from the store: a launcher has no map of its own. + """ + compact = ct.read_store(ENEMY_CORE_SLOT) + if compact == 0: + return + target = Position(compact // 32, compact % 32) + me = ct.get_position() + myTeam = 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) != myTeam + 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, bestD = None, here - LAUNCH_MIN_GAIN + for t in tiles: + d = abs(t.x - target.x) + abs(t.y - target.y) + if d < bestD and ct.can_launch(bot, t): + best, bestD = t, d + if best is not None: + ct.launch(bot, best) + return + + def _build_launchpad(self, ct: Controller, myLoc: Position) -> bool: + """Turn-one pad, 2-4 tiles out on the enemy side. Any builder, no role.""" + if ct.get_current_round() > LAUNCHPAD_ROUND_LIMIT or not ct.can_act(): + return False + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return False + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + return False # one is enough; vision covers the home area + best = None + for d in CARDINALS: + spot = myLoc.add(d) + dc = abs(spot.x - teamCore.x) + abs(spot.y - teamCore.y) + if not (LAUNCHPAD_MIN_D <= dc <= LAUNCHPAD_MAX_D): + continue + if not ct.can_build_launcher(spot): + continue + key = abs(spot.x - target.x) + abs(spot.y - target.y) + if best is None or key < best[0]: + best = (key, spot) + if best is None: + return False + ct.build_launcher(best[1]) + return True + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + self._publish_enemy_core(ct) + if self._build_launchpad(ct, myLoc): + return + 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_v23_launchpad4/mapPathfinding.py b/bots/exp_v23_launchpad4/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v23_launchpad4/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_v23_launchpad5/README.md b/bots/exp_v23_launchpad5/README.md new file mode 100644 index 0000000..9d792a2 --- /dev/null +++ b/bots/exp_v23_launchpad5/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_v23_launchpad5/main.py b/bots/exp_v23_launchpad5/main.py new file mode 100644 index 0000000..0497485 --- /dev/null +++ b/bots/exp_v23_launchpad5/main.py @@ -0,0 +1,861 @@ +"""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 +# LAUNCH PAD, done Pantheon's way. Traced from their 0-5 sweep of our v22: +# launcher built at t1, 3-4 tiles from their own core +# t2 builder (3,4)->(4,10) d7 t3 (2,4)->(4,10) d8 +# t4 (4,4)->(1,6) d5 t5 (4,15)->(1,17) d5 +# They build the pad on TURN ONE and throw every newly spawned builder 7-8 tiles +# on the 1-turn cooldown, several to the same staging tile. Their first gun +# lands t13; ours lands t22-31 because our builders walk. +# Two earlier attempts failed for implementation reasons, not because the +# mechanic is wrong: v1 gated the pad behind `resources > 120` AND the defender +# role so it went up late, and placed it 1-2 tiles from the core instead of the +# 3-4 where spawning builders actually stand. v2 added routing and still sat at +# t22 - and accidentally built 6 pads, proving pad COUNT was never the issue. +# Timing and placement were. +LAUNCHPAD_ROUND_LIMIT = 8 # pad goes up immediately or not at all +LAUNCHPAD_MIN_D = 2 # far enough out that spawned builders reach it +LAUNCHPAD_MAX_D = 4 +LAUNCH_MIN_GAIN = 3 +# v3 got the pad right - one, at t1, 2-4 tiles out - and still launched ZERO +# times. Cause: every entity gets its own Player, and LAUNCHERS NEVER CALL +# setupMap(), so a launcher's mapPf.enemyCorePos is permanently None and the +# throw routine returned immediately. (mapPathfinding.py notes the same trap for +# gunners.) Builders do have the map, so they publish the target here and the +# launcher reads it. Slots 0-8 are taken: 0 numSpawned, 1-6 map share, 7 team +# core, 8 symmetry mask. +ENEMY_CORE_SLOT = 9 +# v4 launched 8.5 times a game starting t2 - mechanically identical to Pantheon - +# and first gun still went t24 -> t26. The pad throws ANY builder in pickup +# range, and in the opening that is mostly the economy crew, so we were flinging +# harvesters and routers across the map. Attackers now walk onto the pad +# deliberately and the launcher only throws a builder that is standing on the +# claim tile, so economy units working near the core are never picked up. +LAUNCH_CLAIM_SLOT = 10 + +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) + elif etype == EntityType.LAUNCHER: + self.runLauncher(ct) + + def _publish_enemy_core(self, ct: Controller) -> None: + """Share the target so map-less entities (launchers) can use it.""" + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return + ct.write_store(ENEMY_CORE_SLOT, target.x * 32 + target.y) + + def runLauncher(self, ct: Controller) -> None: + """Throw any friendly builder in pickup range toward their core. + + Reads the target from the store: a launcher has no map of its own. + """ + compact = ct.read_store(ENEMY_CORE_SLOT) + if compact == 0: + return + target = Position(compact // 32, compact % 32) + me = ct.get_position() + myTeam = ct.get_team() + tiles = ct.get_attackable_tiles_from(me, Direction.NORTH, EntityType.LAUNCHER) + if not tiles: + return + claim = ct.read_store(LAUNCH_CLAIM_SLOT) + if claim == 0: + return # nobody is asking for a ride this turn + want = Position(claim // 32, claim % 32) + for uid in ct.get_nearby_units(): + if (ct.get_team(uid) != myTeam + or ct.get_entity_type(uid) != EntityType.BUILDER_BOT): + continue + bot = ct.get_position(uid) + if bot.x != want.x or bot.y != want.y: + continue # only the builder that claimed the ride + here = abs(bot.x - target.x) + abs(bot.y - target.y) + best, bestD = None, here - LAUNCH_MIN_GAIN + for t in tiles: + d = abs(t.x - target.x) + abs(t.y - target.y) + if d < bestD and ct.can_launch(bot, t): + best, bestD = t, d + if best is not None: + ct.launch(bot, best) + return + + def _build_launchpad(self, ct: Controller, myLoc: Position) -> bool: + """Turn-one pad, 2-4 tiles out on the enemy side. Any builder, no role.""" + if ct.get_current_round() > LAUNCHPAD_ROUND_LIMIT or not ct.can_act(): + return False + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return False + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + return False # one is enough; vision covers the home area + best = None + for d in CARDINALS: + spot = myLoc.add(d) + dc = abs(spot.x - teamCore.x) + abs(spot.y - teamCore.y) + if not (LAUNCHPAD_MIN_D <= dc <= LAUNCHPAD_MAX_D): + continue + if not ct.can_build_launcher(spot): + continue + key = abs(spot.x - target.x) + abs(spot.y - target.y) + if best is None or key < best[0]: + best = (key, spot) + if best is None: + return False + ct.build_launcher(best[1]) + return True + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + self._publish_enemy_core(ct) + if self._build_launchpad(ct, myLoc): + return + 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 _claim_ride(self, ct: Controller, myLoc: Position) -> bool: + """Attacker-only: walk to the pad and announce that we want the throw.""" + teamCore = self.mapPf.teamCore + target = self.mapPf.enemyCorePos + if teamCore is None or target is None: + return False + if abs(myLoc.x - teamCore.x) + abs(myLoc.y - teamCore.y) > 6: + return False # already out on the map; keep marching + myTeam = ct.get_team() + pad = None + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + pad = ct.get_position(b) + break + if pad is None: + return False + d = abs(myLoc.x - pad.x) + abs(myLoc.y - pad.y) + if d > 2: + self.mapPf.moveTo(ct, pad) + return True + ct.write_store(LAUNCH_CLAIM_SLOT, myLoc.x * 32 + myLoc.y) + return True # stand still this turn and get thrown + + 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 self._claim_ride(ct, myLoc): + return + 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_v23_launchpad5/mapPathfinding.py b/bots/exp_v23_launchpad5/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v23_launchpad5/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_v23_launchpad6/README.md b/bots/exp_v23_launchpad6/README.md new file mode 100644 index 0000000..9d792a2 --- /dev/null +++ b/bots/exp_v23_launchpad6/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_v23_launchpad6/main.py b/bots/exp_v23_launchpad6/main.py new file mode 100644 index 0000000..5ed78be --- /dev/null +++ b/bots/exp_v23_launchpad6/main.py @@ -0,0 +1,870 @@ +"""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 +# LAUNCH PAD, done Pantheon's way. Traced from their 0-5 sweep of our v22: +# launcher built at t1, 3-4 tiles from their own core +# t2 builder (3,4)->(4,10) d7 t3 (2,4)->(4,10) d8 +# t4 (4,4)->(1,6) d5 t5 (4,15)->(1,17) d5 +# They build the pad on TURN ONE and throw every newly spawned builder 7-8 tiles +# on the 1-turn cooldown, several to the same staging tile. Their first gun +# lands t13; ours lands t22-31 because our builders walk. +# Two earlier attempts failed for implementation reasons, not because the +# mechanic is wrong: v1 gated the pad behind `resources > 120` AND the defender +# role so it went up late, and placed it 1-2 tiles from the core instead of the +# 3-4 where spawning builders actually stand. v2 added routing and still sat at +# t22 - and accidentally built 6 pads, proving pad COUNT was never the issue. +# Timing and placement were. +LAUNCHPAD_ROUND_LIMIT = 8 # pad goes up immediately or not at all +LAUNCHPAD_MIN_D = 2 # far enough out that spawned builders reach it +LAUNCHPAD_MAX_D = 4 +LAUNCH_MIN_GAIN = 3 +# v3 got the pad right - one, at t1, 2-4 tiles out - and still launched ZERO +# times. Cause: every entity gets its own Player, and LAUNCHERS NEVER CALL +# setupMap(), so a launcher's mapPf.enemyCorePos is permanently None and the +# throw routine returned immediately. (mapPathfinding.py notes the same trap for +# gunners.) Builders do have the map, so they publish the target here and the +# launcher reads it. Slots 0-8 are taken: 0 numSpawned, 1-6 map share, 7 team +# core, 8 symmetry mask. +ENEMY_CORE_SLOT = 9 +# v4 launched 8.5 times a game starting t2 - mechanically identical to Pantheon - +# and first gun still went t24 -> t26. The pad throws ANY builder in pickup +# range, and in the opening that is mostly the economy crew, so we were flinging +# harvesters and routers across the map. Attackers now walk onto the pad +# deliberately and the launcher only throws a builder that is standing on the +# claim tile, so economy units working near the core are never picked up. +LAUNCH_CLAIM_SLOT = 10 +# v5 made the mechanism real - 17.1 launches a game, first gun t24 -> t20 - and +# still gated 40% (kills 103-188). Pantheon launches 3-4 times a WHOLE GAME; we +# launched 17. Our attackers kept returning for another ride: walk to the pad, +# stand still to be thrown, land, drift back inside the claim radius, repeat. +# The ride overhead swamped the 7 tiles it bought. One ride per builder, ever. + +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.hasRidden = False + 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) + elif etype == EntityType.LAUNCHER: + self.runLauncher(ct) + + def _publish_enemy_core(self, ct: Controller) -> None: + """Share the target so map-less entities (launchers) can use it.""" + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return + ct.write_store(ENEMY_CORE_SLOT, target.x * 32 + target.y) + + def runLauncher(self, ct: Controller) -> None: + """Throw any friendly builder in pickup range toward their core. + + Reads the target from the store: a launcher has no map of its own. + """ + compact = ct.read_store(ENEMY_CORE_SLOT) + if compact == 0: + return + target = Position(compact // 32, compact % 32) + me = ct.get_position() + myTeam = ct.get_team() + tiles = ct.get_attackable_tiles_from(me, Direction.NORTH, EntityType.LAUNCHER) + if not tiles: + return + claim = ct.read_store(LAUNCH_CLAIM_SLOT) + if claim == 0: + return # nobody is asking for a ride this turn + want = Position(claim // 32, claim % 32) + for uid in ct.get_nearby_units(): + if (ct.get_team(uid) != myTeam + or ct.get_entity_type(uid) != EntityType.BUILDER_BOT): + continue + bot = ct.get_position(uid) + if bot.x != want.x or bot.y != want.y: + continue # only the builder that claimed the ride + here = abs(bot.x - target.x) + abs(bot.y - target.y) + best, bestD = None, here - LAUNCH_MIN_GAIN + for t in tiles: + d = abs(t.x - target.x) + abs(t.y - target.y) + if d < bestD and ct.can_launch(bot, t): + best, bestD = t, d + if best is not None: + ct.launch(bot, best) + return + + def _build_launchpad(self, ct: Controller, myLoc: Position) -> bool: + """Turn-one pad, 2-4 tiles out on the enemy side. Any builder, no role.""" + if ct.get_current_round() > LAUNCHPAD_ROUND_LIMIT or not ct.can_act(): + return False + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return False + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + return False # one is enough; vision covers the home area + best = None + for d in CARDINALS: + spot = myLoc.add(d) + dc = abs(spot.x - teamCore.x) + abs(spot.y - teamCore.y) + if not (LAUNCHPAD_MIN_D <= dc <= LAUNCHPAD_MAX_D): + continue + if not ct.can_build_launcher(spot): + continue + key = abs(spot.x - target.x) + abs(spot.y - target.y) + if best is None or key < best[0]: + best = (key, spot) + if best is None: + return False + ct.build_launcher(best[1]) + return True + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + self._publish_enemy_core(ct) + if self._build_launchpad(ct, myLoc): + return + 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 _claim_ride(self, ct: Controller, myLoc: Position) -> bool: + """Attacker-only, once per builder: walk to the pad and claim the throw.""" + if getattr(self, "hasRidden", False): + return False + teamCore = self.mapPf.teamCore + target = self.mapPf.enemyCorePos + if teamCore is None or target is None: + return False + if abs(myLoc.x - teamCore.x) + abs(myLoc.y - teamCore.y) > 6: + return False # already out on the map; keep marching + myTeam = ct.get_team() + pad = None + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + pad = ct.get_position(b) + break + if pad is None: + return False + d = abs(myLoc.x - pad.x) + abs(myLoc.y - pad.y) + if d > 2: + self.mapPf.moveTo(ct, pad) + return True + ct.write_store(LAUNCH_CLAIM_SLOT, myLoc.x * 32 + myLoc.y) + self.hasRidden = True # one ride each - never come back + return True # stand still this turn and get thrown + + 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 self._claim_ride(ct, myLoc): + return + 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_v23_launchpad6/mapPathfinding.py b/bots/exp_v23_launchpad6/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v23_launchpad6/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_v23_launchpad7/README.md b/bots/exp_v23_launchpad7/README.md new file mode 100644 index 0000000..9d792a2 --- /dev/null +++ b/bots/exp_v23_launchpad7/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_v23_launchpad7/main.py b/bots/exp_v23_launchpad7/main.py new file mode 100644 index 0000000..806da68 --- /dev/null +++ b/bots/exp_v23_launchpad7/main.py @@ -0,0 +1,879 @@ +"""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 +# LAUNCH PAD, done Pantheon's way. Traced from their 0-5 sweep of our v22: +# launcher built at t1, 3-4 tiles from their own core +# t2 builder (3,4)->(4,10) d7 t3 (2,4)->(4,10) d8 +# t4 (4,4)->(1,6) d5 t5 (4,15)->(1,17) d5 +# They build the pad on TURN ONE and throw every newly spawned builder 7-8 tiles +# on the 1-turn cooldown, several to the same staging tile. Their first gun +# lands t13; ours lands t22-31 because our builders walk. +# Two earlier attempts failed for implementation reasons, not because the +# mechanic is wrong: v1 gated the pad behind `resources > 120` AND the defender +# role so it went up late, and placed it 1-2 tiles from the core instead of the +# 3-4 where spawning builders actually stand. v2 added routing and still sat at +# t22 - and accidentally built 6 pads, proving pad COUNT was never the issue. +# Timing and placement were. +LAUNCHPAD_ROUND_LIMIT = 8 # pad goes up immediately or not at all +LAUNCHPAD_MIN_D = 2 # far enough out that spawned builders reach it +LAUNCHPAD_MAX_D = 4 +LAUNCH_MIN_GAIN = 3 +# v3 got the pad right - one, at t1, 2-4 tiles out - and still launched ZERO +# times. Cause: every entity gets its own Player, and LAUNCHERS NEVER CALL +# setupMap(), so a launcher's mapPf.enemyCorePos is permanently None and the +# throw routine returned immediately. (mapPathfinding.py notes the same trap for +# gunners.) Builders do have the map, so they publish the target here and the +# launcher reads it. Slots 0-8 are taken: 0 numSpawned, 1-6 map share, 7 team +# core, 8 symmetry mask. +ENEMY_CORE_SLOT = 9 +# v4 launched 8.5 times a game starting t2 - mechanically identical to Pantheon - +# and first gun still went t24 -> t26. The pad throws ANY builder in pickup +# range, and in the opening that is mostly the economy crew, so we were flinging +# harvesters and routers across the map. Attackers now walk onto the pad +# deliberately and the launcher only throws a builder that is standing on the +# claim tile, so economy units working near the core are never picked up. +LAUNCH_CLAIM_SLOT = 10 +# v5 launched 17.1x/game (Pantheon: 3-4) and gated 40%: attackers drifted back +# inside the claim radius and rode again and again, and the round trip cost more +# than the 7 tiles bought. v6 tried a one-ride-per-builder flag and collapsed to +# 0.8 launches, because the flag was set when a builder CLAIMED and only one +# claim per turn survives the single slot - everyone who lost the race marked +# itself done and never retried. +# The geometry does the job without a flag: only throw to a tile that lands the +# builder OUTSIDE the claim radius. A thrown builder is then never eligible +# again, so each rides once, and nobody is disqualified by losing a race. +# Slot 7 already holds our own core, which is what a map-less launcher needs. +TEAM_CORE_SLOT = 7 +LAUNCH_MIN_EXIT = 7 # must land at least this far from our own core + +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) + elif etype == EntityType.LAUNCHER: + self.runLauncher(ct) + + def _publish_enemy_core(self, ct: Controller) -> None: + """Share the target so map-less entities (launchers) can use it.""" + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return + ct.write_store(ENEMY_CORE_SLOT, target.x * 32 + target.y) + + def runLauncher(self, ct: Controller) -> None: + """Throw any friendly builder in pickup range toward their core. + + Reads the target from the store: a launcher has no map of its own. + """ + compact = ct.read_store(ENEMY_CORE_SLOT) + if compact == 0: + return + target = Position(compact // 32, compact % 32) + me = ct.get_position() + myTeam = ct.get_team() + tiles = ct.get_attackable_tiles_from(me, Direction.NORTH, EntityType.LAUNCHER) + if not tiles: + return + claim = ct.read_store(LAUNCH_CLAIM_SLOT) + if claim == 0: + return # nobody is asking for a ride this turn + want = Position(claim // 32, claim % 32) + for uid in ct.get_nearby_units(): + if (ct.get_team(uid) != myTeam + or ct.get_entity_type(uid) != EntityType.BUILDER_BOT): + continue + bot = ct.get_position(uid) + if bot.x != want.x or bot.y != want.y: + continue # only the builder that claimed the ride + here = abs(bot.x - target.x) + abs(bot.y - target.y) + homeCompact = ct.read_store(TEAM_CORE_SLOT) + home = (Position(homeCompact // 32, homeCompact % 32) + if homeCompact else None) + best, bestD = None, here - LAUNCH_MIN_GAIN + for t in tiles: + if home is not None: + if abs(t.x - home.x) + abs(t.y - home.y) < LAUNCH_MIN_EXIT: + continue # too short: they would ride again + d = abs(t.x - target.x) + abs(t.y - target.y) + if d < bestD and ct.can_launch(bot, t): + best, bestD = t, d + if best is not None: + ct.launch(bot, best) + return + + def _build_launchpad(self, ct: Controller, myLoc: Position) -> bool: + """Turn-one pad, 2-4 tiles out on the enemy side. Any builder, no role.""" + if ct.get_current_round() > LAUNCHPAD_ROUND_LIMIT or not ct.can_act(): + return False + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return False + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + return False # one is enough; vision covers the home area + best = None + for d in CARDINALS: + spot = myLoc.add(d) + dc = abs(spot.x - teamCore.x) + abs(spot.y - teamCore.y) + if not (LAUNCHPAD_MIN_D <= dc <= LAUNCHPAD_MAX_D): + continue + if not ct.can_build_launcher(spot): + continue + key = abs(spot.x - target.x) + abs(spot.y - target.y) + if best is None or key < best[0]: + best = (key, spot) + if best is None: + return False + ct.build_launcher(best[1]) + return True + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + self._publish_enemy_core(ct) + if self._build_launchpad(ct, myLoc): + return + 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 _claim_ride(self, ct: Controller, myLoc: Position) -> bool: + """Attacker-only: walk to the pad and announce that we want the throw.""" + teamCore = self.mapPf.teamCore + target = self.mapPf.enemyCorePos + if teamCore is None or target is None: + return False + if abs(myLoc.x - teamCore.x) + abs(myLoc.y - teamCore.y) > 6: + return False # already out on the map; keep marching + myTeam = ct.get_team() + pad = None + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + pad = ct.get_position(b) + break + if pad is None: + return False + d = abs(myLoc.x - pad.x) + abs(myLoc.y - pad.y) + if d > 2: + self.mapPf.moveTo(ct, pad) + return True + ct.write_store(LAUNCH_CLAIM_SLOT, myLoc.x * 32 + myLoc.y) + return True # stand still this turn and get thrown + + 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 self._claim_ride(ct, myLoc): + return + 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_v23_launchpad7/mapPathfinding.py b/bots/exp_v23_launchpad7/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v23_launchpad7/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_v23_launchpad8/README.md b/bots/exp_v23_launchpad8/README.md new file mode 100644 index 0000000..9d792a2 --- /dev/null +++ b/bots/exp_v23_launchpad8/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_v23_launchpad8/main.py b/bots/exp_v23_launchpad8/main.py new file mode 100644 index 0000000..cd5b900 --- /dev/null +++ b/bots/exp_v23_launchpad8/main.py @@ -0,0 +1,891 @@ +"""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 +# LAUNCH PAD, done Pantheon's way. Traced from their 0-5 sweep of our v22: +# launcher built at t1, 3-4 tiles from their own core +# t2 builder (3,4)->(4,10) d7 t3 (2,4)->(4,10) d8 +# t4 (4,4)->(1,6) d5 t5 (4,15)->(1,17) d5 +# They build the pad on TURN ONE and throw every newly spawned builder 7-8 tiles +# on the 1-turn cooldown, several to the same staging tile. Their first gun +# lands t13; ours lands t22-31 because our builders walk. +# Two earlier attempts failed for implementation reasons, not because the +# mechanic is wrong: v1 gated the pad behind `resources > 120` AND the defender +# role so it went up late, and placed it 1-2 tiles from the core instead of the +# 3-4 where spawning builders actually stand. v2 added routing and still sat at +# t22 - and accidentally built 6 pads, proving pad COUNT was never the issue. +# Timing and placement were. +LAUNCHPAD_ROUND_LIMIT = 8 # pad goes up immediately or not at all +LAUNCHPAD_MIN_D = 2 # far enough out that spawned builders reach it +LAUNCHPAD_MAX_D = 4 +LAUNCH_MIN_GAIN = 3 +# v3 got the pad right - one, at t1, 2-4 tiles out - and still launched ZERO +# times. Cause: every entity gets its own Player, and LAUNCHERS NEVER CALL +# setupMap(), so a launcher's mapPf.enemyCorePos is permanently None and the +# throw routine returned immediately. (mapPathfinding.py notes the same trap for +# gunners.) Builders do have the map, so they publish the target here and the +# launcher reads it. Slots 0-8 are taken: 0 numSpawned, 1-6 map share, 7 team +# core, 8 symmetry mask. +ENEMY_CORE_SLOT = 9 +# v4 launched 8.5 times a game starting t2 - mechanically identical to Pantheon - +# and first gun still went t24 -> t26. The pad throws ANY builder in pickup +# range, and in the opening that is mostly the economy crew, so we were flinging +# harvesters and routers across the map. Attackers now walk onto the pad +# deliberately and the launcher only throws a builder that is standing on the +# claim tile, so economy units working near the core are never picked up. +LAUNCH_CLAIM_SLOT = 10 +# v5 launched 17.1x/game (Pantheon: 3-4) and gated 40%: attackers drifted back +# inside the claim radius and rode again and again, and the round trip cost more +# than the 7 tiles bought. v6 tried a one-ride-per-builder flag and collapsed to +# 0.8 launches, because the flag was set when a builder CLAIMED and only one +# claim per turn survives the single slot - everyone who lost the race marked +# itself done and never retried. +# The geometry does the job without a flag: only throw to a tile that lands the +# builder OUTSIDE the claim radius. A thrown builder is then never eligible +# again, so each rides once, and nobody is disqualified by losing a race. +# Slot 7 already holds our own core, which is what a map-less launcher needs. +TEAM_CORE_SLOT = 7 +LAUNCH_MIN_EXIT = 7 # must land at least this far from our own core + +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) + elif etype == EntityType.LAUNCHER: + self.runLauncher(ct) + + def _publish_enemy_core(self, ct: Controller) -> None: + """Share the target so map-less entities (launchers) can use it.""" + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return + ct.write_store(ENEMY_CORE_SLOT, target.x * 32 + target.y) + + def runLauncher(self, ct: Controller) -> None: + """Throw any friendly builder in pickup range toward their core. + + Reads the target from the store: a launcher has no map of its own. + """ + compact = ct.read_store(ENEMY_CORE_SLOT) + if compact == 0: + return + target = Position(compact // 32, compact % 32) + me = ct.get_position() + myTeam = ct.get_team() + tiles = ct.get_attackable_tiles_from(me, Direction.NORTH, EntityType.LAUNCHER) + if not tiles: + return + claim = ct.read_store(LAUNCH_CLAIM_SLOT) + if claim == 0: + return # nobody is asking for a ride this turn + want = Position(claim // 32, claim % 32) + for uid in ct.get_nearby_units(): + if (ct.get_team(uid) != myTeam + or ct.get_entity_type(uid) != EntityType.BUILDER_BOT): + continue + bot = ct.get_position(uid) + if bot.x != want.x or bot.y != want.y: + continue # only the builder that claimed the ride + here = abs(bot.x - target.x) + abs(bot.y - target.y) + homeCompact = ct.read_store(TEAM_CORE_SLOT) + home = (Position(homeCompact // 32, homeCompact % 32) + if homeCompact else None) + best, bestD = None, here - LAUNCH_MIN_GAIN + for t in tiles: + if home is not None: + if abs(t.x - home.x) + abs(t.y - home.y) < LAUNCH_MIN_EXIT: + continue # too short: they would ride again + d = abs(t.x - target.x) + abs(t.y - target.y) + if d < bestD and ct.can_launch(bot, t): + best, bestD = t, d + if best is not None: + ct.launch(bot, best) + return + + def _build_launchpad(self, ct: Controller, myLoc: Position) -> bool: + """Turn-one pad, 2-4 tiles out on the enemy side. Any builder, no role.""" + if ct.get_current_round() > LAUNCHPAD_ROUND_LIMIT or not ct.can_act(): + return False + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return False + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + return False # one is enough; vision covers the home area + best = None + for d in CARDINALS: + spot = myLoc.add(d) + dc = abs(spot.x - teamCore.x) + abs(spot.y - teamCore.y) + if not (LAUNCHPAD_MIN_D <= dc <= LAUNCHPAD_MAX_D): + continue + if not ct.can_build_launcher(spot): + continue + key = abs(spot.x - target.x) + abs(spot.y - target.y) + if best is None or key < best[0]: + best = (key, spot) + if best is None: + return False + ct.build_launcher(best[1]) + return True + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + self._publish_enemy_core(ct) + if self._build_launchpad(ct, myLoc): + return + 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 _claim_ride(self, ct: Controller, myLoc: Position) -> bool: + """Attacker-only: walk to the pad and announce that we want the throw. + + Replay trace of launchpad7 showed our builders walking the ENTIRE way - + t2:20, t3:19, t4:18 - never once thrown, while Pantheon's are already at + 14 by t2. Cause: this bailed out when mapPf.enemyCorePos is None, which + is precisely the opening before symmetry resolves. The launcher already + had a fallback target; the rider did not, so the pad sat unused through + the only turns where a six-tile head start is worth anything. + """ + teamCore = self.mapPf.teamCore + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if teamCore is None or target is None: + return False + if abs(myLoc.x - teamCore.x) + abs(myLoc.y - teamCore.y) > 6: + return False # already out on the map; keep marching + myTeam = ct.get_team() + pad = None + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + pad = ct.get_position(b) + break + if pad is None: + return False + d = abs(myLoc.x - pad.x) + abs(myLoc.y - pad.y) + if d > 2: + self.mapPf.moveTo(ct, pad) + return True + ct.write_store(LAUNCH_CLAIM_SLOT, myLoc.x * 32 + myLoc.y) + return True # stand still this turn and get thrown + + 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 self._claim_ride(ct, myLoc): + return + 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_v23_launchpad8/mapPathfinding.py b/bots/exp_v23_launchpad8/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v23_launchpad8/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_v23_launchpad9/README.md b/bots/exp_v23_launchpad9/README.md new file mode 100644 index 0000000..9d792a2 --- /dev/null +++ b/bots/exp_v23_launchpad9/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_v23_launchpad9/main.py b/bots/exp_v23_launchpad9/main.py new file mode 100644 index 0000000..4c3fc9c --- /dev/null +++ b/bots/exp_v23_launchpad9/main.py @@ -0,0 +1,900 @@ +"""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 +# LAUNCH PAD, done Pantheon's way. Traced from their 0-5 sweep of our v22: +# launcher built at t1, 3-4 tiles from their own core +# t2 builder (3,4)->(4,10) d7 t3 (2,4)->(4,10) d8 +# t4 (4,4)->(1,6) d5 t5 (4,15)->(1,17) d5 +# They build the pad on TURN ONE and throw every newly spawned builder 7-8 tiles +# on the 1-turn cooldown, several to the same staging tile. Their first gun +# lands t13; ours lands t22-31 because our builders walk. +# Two earlier attempts failed for implementation reasons, not because the +# mechanic is wrong: v1 gated the pad behind `resources > 120` AND the defender +# role so it went up late, and placed it 1-2 tiles from the core instead of the +# 3-4 where spawning builders actually stand. v2 added routing and still sat at +# t22 - and accidentally built 6 pads, proving pad COUNT was never the issue. +# Timing and placement were. +LAUNCHPAD_ROUND_LIMIT = 8 # pad goes up immediately or not at all +LAUNCHPAD_MIN_D = 2 # far enough out that spawned builders reach it +LAUNCHPAD_MAX_D = 4 +LAUNCH_MIN_GAIN = 3 +# v3 got the pad right - one, at t1, 2-4 tiles out - and still launched ZERO +# times. Cause: every entity gets its own Player, and LAUNCHERS NEVER CALL +# setupMap(), so a launcher's mapPf.enemyCorePos is permanently None and the +# throw routine returned immediately. (mapPathfinding.py notes the same trap for +# gunners.) Builders do have the map, so they publish the target here and the +# launcher reads it. Slots 0-8 are taken: 0 numSpawned, 1-6 map share, 7 team +# core, 8 symmetry mask. +ENEMY_CORE_SLOT = 9 +# v4 launched 8.5 times a game starting t2 - mechanically identical to Pantheon - +# and first gun still went t24 -> t26. The pad throws ANY builder in pickup +# range, and in the opening that is mostly the economy crew, so we were flinging +# harvesters and routers across the map. Attackers now walk onto the pad +# deliberately and the launcher only throws a builder that is standing on the +# claim tile, so economy units working near the core are never picked up. +LAUNCH_CLAIM_SLOT = 10 +# v5 launched 17.1x/game (Pantheon: 3-4) and gated 40%: attackers drifted back +# inside the claim radius and rode again and again, and the round trip cost more +# than the 7 tiles bought. v6 tried a one-ride-per-builder flag and collapsed to +# 0.8 launches, because the flag was set when a builder CLAIMED and only one +# claim per turn survives the single slot - everyone who lost the race marked +# itself done and never retried. +# The geometry does the job without a flag: only throw to a tile that lands the +# builder OUTSIDE the claim radius. A thrown builder is then never eligible +# again, so each rides once, and nobody is disqualified by losing a race. +# Slot 7 already holds our own core, which is what a map-less launcher needs. +TEAM_CORE_SLOT = 7 +LAUNCH_MIN_EXIT = 7 +# v8 DEADLOCKED: riders reached the pad, wrote their claim and stood still +# waiting for a throw that never came - only one claim survives the single slot +# each turn, so the losers waited forever (traced: b3 frozen t6 -> t89). +# Wait at most this many turns, then give up and walk like everyone else. +LAUNCH_WAIT_LIMIT = 2 # must land at least this far from our own core + +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.rideWait = 0 + 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) + elif etype == EntityType.LAUNCHER: + self.runLauncher(ct) + + def _publish_enemy_core(self, ct: Controller) -> None: + """Share the target so map-less entities (launchers) can use it.""" + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return + ct.write_store(ENEMY_CORE_SLOT, target.x * 32 + target.y) + + def runLauncher(self, ct: Controller) -> None: + """Throw any friendly builder in pickup range toward their core. + + Reads the target from the store: a launcher has no map of its own. + """ + compact = ct.read_store(ENEMY_CORE_SLOT) + if compact == 0: + return + target = Position(compact // 32, compact % 32) + me = ct.get_position() + myTeam = ct.get_team() + tiles = ct.get_attackable_tiles_from(me, Direction.NORTH, EntityType.LAUNCHER) + if not tiles: + return + claim = ct.read_store(LAUNCH_CLAIM_SLOT) + if claim == 0: + return # nobody is asking for a ride this turn + want = Position(claim // 32, claim % 32) + for uid in ct.get_nearby_units(): + if (ct.get_team(uid) != myTeam + or ct.get_entity_type(uid) != EntityType.BUILDER_BOT): + continue + bot = ct.get_position(uid) + if bot.x != want.x or bot.y != want.y: + continue # only the builder that claimed the ride + here = abs(bot.x - target.x) + abs(bot.y - target.y) + homeCompact = ct.read_store(TEAM_CORE_SLOT) + home = (Position(homeCompact // 32, homeCompact % 32) + if homeCompact else None) + best, bestD = None, here - LAUNCH_MIN_GAIN + for t in tiles: + if home is not None: + if abs(t.x - home.x) + abs(t.y - home.y) < LAUNCH_MIN_EXIT: + continue # too short: they would ride again + d = abs(t.x - target.x) + abs(t.y - target.y) + if d < bestD and ct.can_launch(bot, t): + best, bestD = t, d + if best is not None: + ct.launch(bot, best) + return + + def _build_launchpad(self, ct: Controller, myLoc: Position) -> bool: + """Turn-one pad, 2-4 tiles out on the enemy side. Any builder, no role.""" + if ct.get_current_round() > LAUNCHPAD_ROUND_LIMIT or not ct.can_act(): + return False + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return False + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + return False # one is enough; vision covers the home area + best = None + for d in CARDINALS: + spot = myLoc.add(d) + dc = abs(spot.x - teamCore.x) + abs(spot.y - teamCore.y) + if not (LAUNCHPAD_MIN_D <= dc <= LAUNCHPAD_MAX_D): + continue + if not ct.can_build_launcher(spot): + continue + key = abs(spot.x - target.x) + abs(spot.y - target.y) + if best is None or key < best[0]: + best = (key, spot) + if best is None: + return False + ct.build_launcher(best[1]) + return True + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + self._publish_enemy_core(ct) + if self._build_launchpad(ct, myLoc): + return + 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 _claim_ride(self, ct: Controller, myLoc: Position) -> bool: + """Attacker-only: walk to the pad and announce that we want the throw. + + Replay trace of launchpad7 showed our builders walking the ENTIRE way - + t2:20, t3:19, t4:18 - never once thrown, while Pantheon's are already at + 14 by t2. Cause: this bailed out when mapPf.enemyCorePos is None, which + is precisely the opening before symmetry resolves. The launcher already + had a fallback target; the rider did not, so the pad sat unused through + the only turns where a six-tile head start is worth anything. + """ + teamCore = self.mapPf.teamCore + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if teamCore is None or target is None: + return False + if abs(myLoc.x - teamCore.x) + abs(myLoc.y - teamCore.y) > 6: + return False # already out on the map; keep marching + myTeam = ct.get_team() + pad = None + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + pad = ct.get_position(b) + break + if pad is None: + return False + d = abs(myLoc.x - pad.x) + abs(myLoc.y - pad.y) + if d > 2: + self.mapPf.moveTo(ct, pad) + return True + self.rideWait += 1 + if self.rideWait > LAUNCH_WAIT_LIMIT: + return False # someone else has the pad; just march + ct.write_store(LAUNCH_CLAIM_SLOT, myLoc.x * 32 + myLoc.y) + return True # stand still this turn and get thrown + + 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 self._claim_ride(ct, myLoc): + return + 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_v23_launchpad9/mapPathfinding.py b/bots/exp_v23_launchpad9/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/exp_v23_launchpad9/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_v24_launcher_eye/main.py b/bots/exp_v24_launcher_eye/main.py new file mode 100644 index 0000000..9104a53 --- /dev/null +++ b/bots/exp_v24_launcher_eye/main.py @@ -0,0 +1,777 @@ +"""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 +# LAUNCHER AS AN EYE. Pantheon report that full symmetry resolves shortly after +# their launcher goes down. The constants say why: a launcher sees radius^2 26 +# to a builder's 20, and symmetry is eliminated by observing terrain. A pad at +# home is therefore a permanent wide sensor, and knowing the enemy core early is +# what lets a thrown builder be thrown the RIGHT way. +# Pad placement is off the core->enemy lane, because our own buildings block our +# own units and putting it on the axis cost eight turns of oscillation per +# builder when measured. +EYE_ROUND_LIMIT = 8 +EYE_MIN_D = 2 +EYE_MAX_D = 4 +ENEMY_CORE_SLOT = 9 + +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) + elif etype == EntityType.LAUNCHER: + self.runLauncherEye(ct) + + def runLauncherEye(self, ct: Controller) -> None: + """A launcher earns its 20 Ti as a sensor before it throws anything.""" + self.mapPf.setupMapObserver(ct) + target = self.mapPf.enemyCorePos + if target is not None: + ct.write_store(ENEMY_CORE_SLOT, target.x * 32 + target.y) + + def _buildEye(self, ct: Controller, myLoc: Position) -> bool: + if ct.get_current_round() > EYE_ROUND_LIMIT or not ct.can_act(): + return False + teamCore = self.mapPf.teamCore + if teamCore is None: + return False + target = self.mapPf.enemyCorePos + if target is None: + for cand in self.mapPf.allEnemyCore.values(): + target = cand + break + if target is None: + return False + myTeam = ct.get_team() + for b in ct.get_nearby_buildings(): + if (ct.get_team(b) == myTeam + and ct.get_entity_type(b) == EntityType.LAUNCHER): + return False + axis_x = 1 if abs(target.x - teamCore.x) >= abs(target.y - teamCore.y) else 0 + best = None + for d in CARDINALS: + spot = myLoc.add(d) + dc = abs(spot.x - teamCore.x) + abs(spot.y - teamCore.y) + if not (EYE_MIN_D <= dc <= EYE_MAX_D): + continue + if not ct.can_build_launcher(spot): + continue + perp = abs(spot.y - teamCore.y) if axis_x else abs(spot.x - teamCore.x) + key = (0 if perp >= 1 else 1, + abs(spot.x - target.x) + abs(spot.y - target.y)) + if best is None or key < best[0]: + best = (key, spot) + if best is None: + return False + ct.build_launcher(best[1]) + return True + + def builderBot(self, ct: Controller): + myLoc = ct.get_position() + self.mapPf.setupMap(ct) + if self._buildEye(ct, myLoc): + return + 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_v24_launcher_eye/mapPathfinding.py b/bots/exp_v24_launcher_eye/mapPathfinding.py new file mode 100644 index 0000000..4e4b5e2 --- /dev/null +++ b/bots/exp_v24_launcher_eye/mapPathfinding.py @@ -0,0 +1,759 @@ +"""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 setupMapObserver(self, ct: Controller): + """Map upkeep for a NON-BUILDER entity: see tiles, fold in the shared + board, and help eliminate symmetries - but never touch shareTiles, + whose 12-bit channel is keyed on myNum and belongs to the builders. + + Why bother: LAUNCHER_VISION_RADIUS_SQ is 26 against a builder's 20, so a + launcher parked at home is the widest permanent sensor we own, and the + symmetry tracker resolves the enemy core by ruling out candidates from + observed terrain. Pantheon report full symmetry shortly after their pad + goes down - which is what lets them throw builders the RIGHT way at t2 + and land a gun at t13. + """ + self.mapChanged = False + 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.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + 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)] + 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)] + self.getNewTiles(ct) + self.updateBoard(ct) + self.updateSymmetry(ct) + + 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_v30_bolt/main.py b/bots/exp_v30_bolt/main.py new file mode 100644 index 0000000..aba6c38 --- /dev/null +++ b/bots/exp_v30_bolt/main.py @@ -0,0 +1,756 @@ +"""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 * + +# COMMITTED BOLT STATE. +# Measured over 70 replays of v29's own matches: turns spent per tile of +# progress toward the enemy core, for builders that actually reach the firing +# band, is 1.50 for us against 1.12 for our opponents (25th 1.11 vs 1.00). We +# spend 34% more turns per tile. Over a typical 14-tile approach that is ~21 +# turns against their ~16, which is most of the first-gun gap (t22-30 vs +# t7-14) - and none of it needs a launcher. +# Cause is visible in runAttack: once the enemy core is known and titanium is +# above 80, findGunnerSpot() runs EVERY TURN and the attacker moveTo()s the +# seat it returns. That seat is rescored each turn from the builder's own +# position, so a distant attacker chases a moving target instead of closing. +# The fix is to commit: outside the seat zone, march at the core and do not +# seat-hunt at all. Inside it, behave exactly as v29 does today. +BOLT_BAND = 9 # start seat-hunting only this close (firing band is 6) + + +# 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.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(28 - globalAmmo, globalTitanium - 16) + 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: + coreBySym = self.mapPf.allEnemyCore + if '180' in coreBySym: + # 180 rotational symmetry is the default assumption - commit most + # attackers there first, but keep a couple checking the alternates + # so a disproof of 180 doesn't leave us starting from zero + candidates = [coreBySym['180']] * 3 + for sym in ('flipX', 'flipY'): + if sym in coreBySym: + candidates.append(coreBySym[sym]) + else: + candidates = list(coreBySym.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: + enemyCore = self.mapPf.enemyCorePos + if (abs(myLoc.x - enemyCore.x) + abs(myLoc.y - enemyCore.y)) > BOLT_BAND: + # BOLT: too far to have a real seat opinion - just close. + self.mapPf.moveTo(ct, enemyCore) + return + 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) + dx, dy = bDir.delta() + maxK = 3 if bDir 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 + covered.add((x, y)) + 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 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 + \ No newline at end of file diff --git a/bots/exp_v30_bolt/mapPathfinding.py b/bots/exp_v30_bolt/mapPathfinding.py new file mode 100644 index 0000000..d7bc966 --- /dev/null +++ b/bots/exp_v30_bolt/mapPathfinding.py @@ -0,0 +1,739 @@ +"""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 +conveyorGunnerCost = 30 # conveyor routing can destroy a gunner too, but discourage it even more +threatPenalty = 2 # extra cost for walking through an enemy turret's covered tiles + +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 = ['180', 'flipX', 'flipY'] # '180' assumed most likely, checked first + 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 tileCostSafe(self, ct: Controller, tile: Position, enemyThreatened) -> int | None: + stepCost = self.tileCost(ct, tile) + if stepCost is not None and (tile.x, tile.y) in enemyThreatened: + return stepCost + threatPenalty + return stepCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * (gunnerCost + threatPenalty) + 8 + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + + 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.tileCostSafe(ct, nextTile, enemyThreatened) + 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 [conveyorGunnerCost, 'working'] # can be routed through/destroyed, but discouraged + 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 + elif posDist == bestDist and not self.createsLoop(curEnd, d): + if myLoc.distance_squared(nextPos) < myLoc.distance_squared(curEnd.add(bestNextDir)): + 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_v30_bolt_13/main.py b/bots/exp_v30_bolt_13/main.py new file mode 100644 index 0000000..1f84fcb --- /dev/null +++ b/bots/exp_v30_bolt_13/main.py @@ -0,0 +1,756 @@ +"""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 * + +# COMMITTED BOLT STATE. +# Measured over 70 replays of v29's own matches: turns spent per tile of +# progress toward the enemy core, for builders that actually reach the firing +# band, is 1.50 for us against 1.12 for our opponents (25th 1.11 vs 1.00). We +# spend 34% more turns per tile. Over a typical 14-tile approach that is ~21 +# turns against their ~16, which is most of the first-gun gap (t22-30 vs +# t7-14) - and none of it needs a launcher. +# Cause is visible in runAttack: once the enemy core is known and titanium is +# above 80, findGunnerSpot() runs EVERY TURN and the attacker moveTo()s the +# seat it returns. That seat is rescored each turn from the builder's own +# position, so a distant attacker chases a moving target instead of closing. +# The fix is to commit: outside the seat zone, march at the core and do not +# seat-hunt at all. Inside it, behave exactly as v29 does today. +BOLT_BAND = 13 # start seat-hunting only this close (firing band is 6) + + +# 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.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(28 - globalAmmo, globalTitanium - 16) + 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: + coreBySym = self.mapPf.allEnemyCore + if '180' in coreBySym: + # 180 rotational symmetry is the default assumption - commit most + # attackers there first, but keep a couple checking the alternates + # so a disproof of 180 doesn't leave us starting from zero + candidates = [coreBySym['180']] * 3 + for sym in ('flipX', 'flipY'): + if sym in coreBySym: + candidates.append(coreBySym[sym]) + else: + candidates = list(coreBySym.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: + enemyCore = self.mapPf.enemyCorePos + if (abs(myLoc.x - enemyCore.x) + abs(myLoc.y - enemyCore.y)) > BOLT_BAND: + # BOLT: too far to have a real seat opinion - just close. + self.mapPf.moveTo(ct, enemyCore) + return + 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) + dx, dy = bDir.delta() + maxK = 3 if bDir 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 + covered.add((x, y)) + 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 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 + \ No newline at end of file diff --git a/bots/exp_v30_bolt_13/mapPathfinding.py b/bots/exp_v30_bolt_13/mapPathfinding.py new file mode 100644 index 0000000..d7bc966 --- /dev/null +++ b/bots/exp_v30_bolt_13/mapPathfinding.py @@ -0,0 +1,739 @@ +"""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 +conveyorGunnerCost = 30 # conveyor routing can destroy a gunner too, but discourage it even more +threatPenalty = 2 # extra cost for walking through an enemy turret's covered tiles + +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 = ['180', 'flipX', 'flipY'] # '180' assumed most likely, checked first + 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 tileCostSafe(self, ct: Controller, tile: Position, enemyThreatened) -> int | None: + stepCost = self.tileCost(ct, tile) + if stepCost is not None and (tile.x, tile.y) in enemyThreatened: + return stepCost + threatPenalty + return stepCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * (gunnerCost + threatPenalty) + 8 + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + + 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.tileCostSafe(ct, nextTile, enemyThreatened) + 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 [conveyorGunnerCost, 'working'] # can be routed through/destroyed, but discouraged + 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 + elif posDist == bestDist and not self.createsLoop(curEnd, d): + if myLoc.distance_squared(nextPos) < myLoc.distance_squared(curEnd.add(bestNextDir)): + 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_v30_bolt_18/main.py b/bots/exp_v30_bolt_18/main.py new file mode 100644 index 0000000..2fced6f --- /dev/null +++ b/bots/exp_v30_bolt_18/main.py @@ -0,0 +1,756 @@ +"""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 * + +# COMMITTED BOLT STATE. +# Measured over 70 replays of v29's own matches: turns spent per tile of +# progress toward the enemy core, for builders that actually reach the firing +# band, is 1.50 for us against 1.12 for our opponents (25th 1.11 vs 1.00). We +# spend 34% more turns per tile. Over a typical 14-tile approach that is ~21 +# turns against their ~16, which is most of the first-gun gap (t22-30 vs +# t7-14) - and none of it needs a launcher. +# Cause is visible in runAttack: once the enemy core is known and titanium is +# above 80, findGunnerSpot() runs EVERY TURN and the attacker moveTo()s the +# seat it returns. That seat is rescored each turn from the builder's own +# position, so a distant attacker chases a moving target instead of closing. +# The fix is to commit: outside the seat zone, march at the core and do not +# seat-hunt at all. Inside it, behave exactly as v29 does today. +BOLT_BAND = 18 # start seat-hunting only this close (firing band is 6) + + +# 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.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(28 - globalAmmo, globalTitanium - 16) + 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: + coreBySym = self.mapPf.allEnemyCore + if '180' in coreBySym: + # 180 rotational symmetry is the default assumption - commit most + # attackers there first, but keep a couple checking the alternates + # so a disproof of 180 doesn't leave us starting from zero + candidates = [coreBySym['180']] * 3 + for sym in ('flipX', 'flipY'): + if sym in coreBySym: + candidates.append(coreBySym[sym]) + else: + candidates = list(coreBySym.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: + enemyCore = self.mapPf.enemyCorePos + if (abs(myLoc.x - enemyCore.x) + abs(myLoc.y - enemyCore.y)) > BOLT_BAND: + # BOLT: too far to have a real seat opinion - just close. + self.mapPf.moveTo(ct, enemyCore) + return + 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) + dx, dy = bDir.delta() + maxK = 3 if bDir 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 + covered.add((x, y)) + 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 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 + \ No newline at end of file diff --git a/bots/exp_v30_bolt_18/mapPathfinding.py b/bots/exp_v30_bolt_18/mapPathfinding.py new file mode 100644 index 0000000..d7bc966 --- /dev/null +++ b/bots/exp_v30_bolt_18/mapPathfinding.py @@ -0,0 +1,739 @@ +"""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 +conveyorGunnerCost = 30 # conveyor routing can destroy a gunner too, but discourage it even more +threatPenalty = 2 # extra cost for walking through an enemy turret's covered tiles + +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 = ['180', 'flipX', 'flipY'] # '180' assumed most likely, checked first + 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 tileCostSafe(self, ct: Controller, tile: Position, enemyThreatened) -> int | None: + stepCost = self.tileCost(ct, tile) + if stepCost is not None and (tile.x, tile.y) in enemyThreatened: + return stepCost + threatPenalty + return stepCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * (gunnerCost + threatPenalty) + 8 + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + + 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.tileCostSafe(ct, nextTile, enemyThreatened) + 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 [conveyorGunnerCost, 'working'] # can be routed through/destroyed, but discouraged + 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 + elif posDist == bestDist and not self.createsLoop(curEnd, d): + if myLoc.distance_squared(nextPos) < myLoc.distance_squared(curEnd.add(bestNextDir)): + 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_v30_bolt_6/main.py b/bots/exp_v30_bolt_6/main.py new file mode 100644 index 0000000..0fb6e9e --- /dev/null +++ b/bots/exp_v30_bolt_6/main.py @@ -0,0 +1,756 @@ +"""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 * + +# COMMITTED BOLT STATE. +# Measured over 70 replays of v29's own matches: turns spent per tile of +# progress toward the enemy core, for builders that actually reach the firing +# band, is 1.50 for us against 1.12 for our opponents (25th 1.11 vs 1.00). We +# spend 34% more turns per tile. Over a typical 14-tile approach that is ~21 +# turns against their ~16, which is most of the first-gun gap (t22-30 vs +# t7-14) - and none of it needs a launcher. +# Cause is visible in runAttack: once the enemy core is known and titanium is +# above 80, findGunnerSpot() runs EVERY TURN and the attacker moveTo()s the +# seat it returns. That seat is rescored each turn from the builder's own +# position, so a distant attacker chases a moving target instead of closing. +# The fix is to commit: outside the seat zone, march at the core and do not +# seat-hunt at all. Inside it, behave exactly as v29 does today. +BOLT_BAND = 6 # start seat-hunting only this close (firing band is 6) + + +# 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.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(28 - globalAmmo, globalTitanium - 16) + 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: + coreBySym = self.mapPf.allEnemyCore + if '180' in coreBySym: + # 180 rotational symmetry is the default assumption - commit most + # attackers there first, but keep a couple checking the alternates + # so a disproof of 180 doesn't leave us starting from zero + candidates = [coreBySym['180']] * 3 + for sym in ('flipX', 'flipY'): + if sym in coreBySym: + candidates.append(coreBySym[sym]) + else: + candidates = list(coreBySym.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: + enemyCore = self.mapPf.enemyCorePos + if (abs(myLoc.x - enemyCore.x) + abs(myLoc.y - enemyCore.y)) > BOLT_BAND: + # BOLT: too far to have a real seat opinion - just close. + self.mapPf.moveTo(ct, enemyCore) + return + 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) + dx, dy = bDir.delta() + maxK = 3 if bDir 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 + covered.add((x, y)) + 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 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 + \ No newline at end of file diff --git a/bots/exp_v30_bolt_6/mapPathfinding.py b/bots/exp_v30_bolt_6/mapPathfinding.py new file mode 100644 index 0000000..d7bc966 --- /dev/null +++ b/bots/exp_v30_bolt_6/mapPathfinding.py @@ -0,0 +1,739 @@ +"""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 +conveyorGunnerCost = 30 # conveyor routing can destroy a gunner too, but discourage it even more +threatPenalty = 2 # extra cost for walking through an enemy turret's covered tiles + +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 = ['180', 'flipX', 'flipY'] # '180' assumed most likely, checked first + 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 tileCostSafe(self, ct: Controller, tile: Position, enemyThreatened) -> int | None: + stepCost = self.tileCost(ct, tile) + if stepCost is not None and (tile.x, tile.y) in enemyThreatened: + return stepCost + threatPenalty + return stepCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * (gunnerCost + threatPenalty) + 8 + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + + 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.tileCostSafe(ct, nextTile, enemyThreatened) + 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 [conveyorGunnerCost, 'working'] # can be routed through/destroyed, but discouraged + 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 + elif posDist == bestDist and not self.createsLoop(curEnd, d): + if myLoc.distance_squared(nextPos) < myLoc.distance_squared(curEnd.add(bestNextDir)): + 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_v30_core_eye/main.py b/bots/exp_v30_core_eye/main.py new file mode 100644 index 0000000..456180e --- /dev/null +++ b/bots/exp_v30_core_eye/main.py @@ -0,0 +1,747 @@ +"""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.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() + # THE CORE IS THE BIGGEST EYE WE OWN AND IT WAS DOING NO MAPPING AT ALL. + # CORE_VISION_RADIUS_SQ is 36 against a builder's 20, and the core never + # moves, so it observes the widest patch from turn zero - yet runCore + # never touched mapPf. + # Why it matters: measured, v29 resolves the enemy core at a MEDIAN of + # t20, and an attacker cannot aim before it knows where to go, so our + # first gun lands t22-30 against Pantheon's t7-14. Symmetry is eliminated + # by observing terrain, and eliminations propagate through the shared + # mask in slot 8, so anything the core rules out helps every builder. + # Observer path only: it must not touch shareTiles, whose channel is + # keyed on myNum and belongs to the builder round-robin. + self.mapPf.setupMapObserver(ct) + + 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(28 - globalAmmo, globalTitanium - 16) + 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: + coreBySym = self.mapPf.allEnemyCore + if '180' in coreBySym: + # 180 rotational symmetry is the default assumption - commit most + # attackers there first, but keep a couple checking the alternates + # so a disproof of 180 doesn't leave us starting from zero + candidates = [coreBySym['180']] * 3 + for sym in ('flipX', 'flipY'): + if sym in coreBySym: + candidates.append(coreBySym[sym]) + else: + candidates = list(coreBySym.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) + dx, dy = bDir.delta() + maxK = 3 if bDir 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 + covered.add((x, y)) + 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 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_v30_core_eye/mapPathfinding.py b/bots/exp_v30_core_eye/mapPathfinding.py new file mode 100644 index 0000000..9220f15 --- /dev/null +++ b/bots/exp_v30_core_eye/mapPathfinding.py @@ -0,0 +1,774 @@ +"""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 +conveyorGunnerCost = 30 # conveyor routing can destroy a gunner too, but discourage it even more +threatPenalty = 2 # extra cost for walking through an enemy turret's covered tiles + +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 = ['180', 'flipX', 'flipY'] # '180' assumed most likely, checked first + 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 setupMapObserver(self, ct: Controller): + """Map upkeep for an entity that must not touch the builders' channel. + + Skips shareTiles, whose 12-bit slots are keyed on myNum and belong to the + builder round-robin. Everything else runs, so this entity's vision feeds + the SHARED symmetry mask (slot 8) that every builder reads. + """ + self.mapChanged = False + if self.teamCore is None: + # A CORE does not appear in its own get_nearby_buildings(), so the + # usual lookup leaves teamCore None and updateSymmetry() returns + # immediately - the observer runs and contributes nothing. Take our + # own position when we ARE the core. + if ct.get_entity_type() == EntityType.CORE: + self.teamCore = ct.get_position() + else: + 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.mapW is None or self.mapH is None: + self.mapW = ct.get_map_width() + self.mapH = ct.get_map_height() + 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)] + 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)] + self.getNewTiles(ct) + self.updateBoard(ct) + self.updateSymmetry(ct) + + 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 tileCostSafe(self, ct: Controller, tile: Position, enemyThreatened) -> int | None: + stepCost = self.tileCost(ct, tile) + if stepCost is not None and (tile.x, tile.y) in enemyThreatened: + return stepCost + threatPenalty + return stepCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * (gunnerCost + threatPenalty) + 8 + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + + 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.tileCostSafe(ct, nextTile, enemyThreatened) + 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 [conveyorGunnerCost, 'working'] # can be routed through/destroyed, but discouraged + 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 + elif posDist == bestDist and not self.createsLoop(curEnd, d): + if myLoc.distance_squared(nextPos) < myLoc.distance_squared(curEnd.add(bestNextDir)): + 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_v30_stack/main.py b/bots/exp_v30_stack/main.py new file mode 100644 index 0000000..1913a5c --- /dev/null +++ b/bots/exp_v30_stack/main.py @@ -0,0 +1,775 @@ +"""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 * + +# COMMITTED BOLT STATE. +# Measured over 70 replays of v29's own matches: turns spent per tile of +# progress toward the enemy core, for builders that actually reach the firing +# band, is 1.50 for us against 1.12 for our opponents (25th 1.11 vs 1.00). We +# spend 34% more turns per tile. Over a typical 14-tile approach that is ~21 +# turns against their ~16, which is most of the first-gun gap (t22-30 vs +# t7-14) - and none of it needs a launcher. +# Cause is visible in runAttack: once the enemy core is known and titanium is +# above 80, findGunnerSpot() runs EVERY TURN and the attacker moveTo()s the +# seat it returns. That seat is rescored each turn from the builder's own +# position, so a distant attacker chases a moving target instead of closing. +# The fix is to commit: outside the seat zone, march at the core and do not +# seat-hunt at all. Inside it, behave exactly as v29 does today. +BOLT_BAND = 9 # start seat-hunting only this close (firing band is 6) +# STACKED with deferred defence. Neither passed alone (bolt 52%, defer 49%), so +# WORKFLOW would normally forbid stacking, but they are complementary rather +# than overlapping: defer stops the OPENING GUN BUDGET going to home defence, +# bolt stops the WALK being wasted, and each independently moves first gun +# t29 -> t25. +DEFENSE_HOLD_SLOT = 11 +DEFENSE_HOLD_MIN_THREATS = 2 + + +# 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.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(28 - globalAmmo, globalTitanium - 16) + 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 _firstBloodDone(self, ct: Controller) -> bool: + return ct.read_store(DEFENSE_HOLD_SLOT) == 1 + + def _markFirstBlood(self, ct: Controller) -> None: + if ct.read_store(DEFENSE_HOLD_SLOT) != 1: + ct.write_store(DEFENSE_HOLD_SLOT, 1) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + coreBySym = self.mapPf.allEnemyCore + if '180' in coreBySym: + # 180 rotational symmetry is the default assumption - commit most + # attackers there first, but keep a couple checking the alternates + # so a disproof of 180 doesn't leave us starting from zero + candidates = [coreBySym['180']] * 3 + for sym in ('flipX', 'flipY'): + if sym in coreBySym: + candidates.append(coreBySym[sym]) + else: + candidates = list(coreBySym.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: + enemyCore = self.mapPf.enemyCorePos + if (abs(myLoc.x - enemyCore.x) + abs(myLoc.y - enemyCore.y)) > BOLT_BAND: + # BOLT: too far to have a real seat opinion - just close. + self.mapPf.moveTo(ct, enemyCore) + return + 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) + self._markFirstBlood(ct) + 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: + if (not self._firstBloodDone(ct) + and len(uncovered) < DEFENSE_HOLD_MIN_THREATS): + self.healCore(ct) + return + 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) + dx, dy = bDir.delta() + maxK = 3 if bDir 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 + covered.add((x, y)) + 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 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 + \ No newline at end of file diff --git a/bots/exp_v30_stack/mapPathfinding.py b/bots/exp_v30_stack/mapPathfinding.py new file mode 100644 index 0000000..d7bc966 --- /dev/null +++ b/bots/exp_v30_stack/mapPathfinding.py @@ -0,0 +1,739 @@ +"""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 +conveyorGunnerCost = 30 # conveyor routing can destroy a gunner too, but discourage it even more +threatPenalty = 2 # extra cost for walking through an enemy turret's covered tiles + +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 = ['180', 'flipX', 'flipY'] # '180' assumed most likely, checked first + 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 tileCostSafe(self, ct: Controller, tile: Position, enemyThreatened) -> int | None: + stepCost = self.tileCost(ct, tile) + if stepCost is not None and (tile.x, tile.y) in enemyThreatened: + return stepCost + threatPenalty + return stepCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * (gunnerCost + threatPenalty) + 8 + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + + 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.tileCostSafe(ct, nextTile, enemyThreatened) + 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 [conveyorGunnerCost, 'working'] # can be routed through/destroyed, but discouraged + 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 + elif posDist == bestDist and not self.createsLoop(curEnd, d): + if myLoc.distance_squared(nextPos) < myLoc.distance_squared(curEnd.add(bestNextDir)): + 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_v30_tempo_first/main.py b/bots/exp_v30_tempo_first/main.py new file mode 100644 index 0000000..fcf1768 --- /dev/null +++ b/bots/exp_v30_tempo_first/main.py @@ -0,0 +1,770 @@ +"""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 * + +# TEMPO FIRST — from the Phase 0 diagnostics on 70 replays of v29's own losing +# matches (2026-08-04): +# D1 52.7% of our guns sit in our OWN half; opponents put 29.1% there. +# D3 our guns are NOT inefficient - they live 9 turns to their 5 and deal +# 37.9 core damage each to their 25.7 (489 a game against 358). +# D2 but we win the race to the first gun on the enemy core in only 15 of 64 +# games (23%), median t20 to their t14, and t29 to t15 in longer games. +# So the opening gun budget goes to home defence and the offensive gun lands +# 6-14 turns late. 23 of 26 losses are core_destroyed at a median of t62 with +# 12 inside t60 - we are killed before our own kill lands. +# This does NOT restrict placement (exp_core_pressure did and gated 46%, +# losing 9 points to rushers). Defence is only DEFERRED: until the team has put +# its first gun on the enemy core, a defensive counter-gun must clear a real +# threat bar, and the defender heals instead. After that, v29 behaviour exactly. +DEFENSE_HOLD_SLOT = 11 # 0-8 taken; 9/10 free, 11 unused here +DEFENSE_HOLD_MIN_THREATS = 2 # before first blood, answer only a real push + + +# 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.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(28 - globalAmmo, globalTitanium - 16) + 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 _firstBloodDone(self, ct: Controller) -> bool: + return ct.read_store(DEFENSE_HOLD_SLOT) == 1 + + def _markFirstBlood(self, ct: Controller) -> None: + if ct.read_store(DEFENSE_HOLD_SLOT) != 1: + ct.write_store(DEFENSE_HOLD_SLOT, 1) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + coreBySym = self.mapPf.allEnemyCore + if '180' in coreBySym: + # 180 rotational symmetry is the default assumption - commit most + # attackers there first, but keep a couple checking the alternates + # so a disproof of 180 doesn't leave us starting from zero + candidates = [coreBySym['180']] * 3 + for sym in ('flipX', 'flipY'): + if sym in coreBySym: + candidates.append(coreBySym[sym]) + else: + candidates = list(coreBySym.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) + self._markFirstBlood(ct) + 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: + # TEMPO FIRST: before the team has landed a gun on their core, a + # single uncovered turret is not worth the opening gun budget - that + # spend is what puts our own first gun 6-14 turns late. Answer only a + # real push (2+), otherwise hold and heal. After first blood this is + # exactly v29. + if (not self._firstBloodDone(ct) + and len(uncovered) < DEFENSE_HOLD_MIN_THREATS): + self.healCore(ct) + return + 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) + dx, dy = bDir.delta() + maxK = 3 if bDir 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 + covered.add((x, y)) + 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 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 + \ No newline at end of file diff --git a/bots/exp_v30_tempo_first/mapPathfinding.py b/bots/exp_v30_tempo_first/mapPathfinding.py new file mode 100644 index 0000000..d7bc966 --- /dev/null +++ b/bots/exp_v30_tempo_first/mapPathfinding.py @@ -0,0 +1,739 @@ +"""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 +conveyorGunnerCost = 30 # conveyor routing can destroy a gunner too, but discourage it even more +threatPenalty = 2 # extra cost for walking through an enemy turret's covered tiles + +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 = ['180', 'flipX', 'flipY'] # '180' assumed most likely, checked first + 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 tileCostSafe(self, ct: Controller, tile: Position, enemyThreatened) -> int | None: + stepCost = self.tileCost(ct, tile) + if stepCost is not None and (tile.x, tile.y) in enemyThreatened: + return stepCost + threatPenalty + return stepCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * (gunnerCost + threatPenalty) + 8 + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + + 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.tileCostSafe(ct, nextTile, enemyThreatened) + 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 [conveyorGunnerCost, 'working'] # can be routed through/destroyed, but discouraged + 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 + elif posDist == bestDist and not self.createsLoop(curEnd, d): + if myLoc.distance_squared(nextPos) < myLoc.distance_squared(curEnd.add(bestNextDir)): + 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_v31_sentinel/main.py b/bots/exp_v31_sentinel/main.py new file mode 100644 index 0000000..b1c322d --- /dev/null +++ b/bots/exp_v31_sentinel/main.py @@ -0,0 +1,774 @@ +"""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 * + +# SENTINEL RUSH — the 2.3.4 rebalance inverts the unit economy. +# gunner sentinel +# dmg/turn 7.0 9.0 +# dmg/ammo 1.75 1.80 +# dmg/Ti 0.35 0.60 <- 71% better per titanium +# HP 25 40 +# reach card 3/diag 2 card 5/diag 4 <- TWO fewer tiles to travel +# Gunners were nerfed on four axes at once (cost 10->20, damage 10->7, ammo +# 2->4, HP 40->25) while sentinels were buffed (cooldown 3->2, HP 30->40). +# The reach matters most here: Phase 0 measured our first gun as TRAVEL-bound, +# and a sentinel threatens the core from 5 tiles instead of 3. +# Attack seats now use sentinel geometry and build sentinels. Defence is left +# on gunners deliberately - one change at a time. + + +# 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.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(28 - globalAmmo, globalTitanium - 16) + 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) + self.setupDefendClamp(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) + else: # economy (defenders 4/6 included, clamped to their home radius) + # 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 setupDefendClamp(self, ct: Controller): + if self.mapPf.myNum != 4 and self.mapPf.myNum != 6: + self.mapPf.clearMoveZone() + return + home = self.getDefendHome(ct) + if home is None: + self.mapPf.clearMoveZone() + return + self.mapPf.setMoveZone(home, self.farCoreCorner()) + + def farCoreCorner(self) -> Position: + core = self.mapPf.teamCore + center = Position(self.mapW // 2, self.mapH // 2) + corners = [core, core.add(Direction.EAST), core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST)] + return max(corners, key=lambda corner: corner.distance_squared(center)) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + coreBySym = self.mapPf.allEnemyCore + if '180' in coreBySym: + # 180 rotational symmetry is the default assumption - commit most + # attackers there first, but keep a couple checking the alternates + # so a disproof of 180 doesn't leave us starting from zero + candidates = [coreBySym['180']] * 3 + for sym in ('flipX', 'flipY'): + if sym in coreBySym: + candidates.append(coreBySym[sym]) + else: + candidates = list(coreBySym.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_sentinel(gunnerSpot, gunnerDir): + ct.build_sentinel(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.sentinelSpots(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) + dx, dy = bDir.delta() + maxK = 3 if bDir 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 + covered.add((x, y)) + 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 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 not self.mapPf.inMoveZone(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 + if not self.mapPf.inMoveZone(self.routeTarget): + self.clearCommittedRoute() + 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: + if ct.get_entity_type(targetId) == EntityType.CONVEYOR: + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + continue + if ct.get_entity_type(targetId) in (EntityType.BARRIER, EntityType.GUNNER): + # our own blocker at the frontier - hand off to routeConveyor + # below, which already knows how to destroy it and rebuild + break + 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) and self.mapPf.inMoveZone(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) and self.mapPf.inMoveZone(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 not self.mapPf.inMoveZone(tile): + continue + 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/exp_v31_sentinel/mapPathfinding.py b/bots/exp_v31_sentinel/mapPathfinding.py new file mode 100644 index 0000000..c0f70d5 --- /dev/null +++ b/bots/exp_v31_sentinel/mapPathfinding.py @@ -0,0 +1,792 @@ +"""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 +conveyorGunnerCost = 30 # conveyor routing can destroy a gunner too, but discourage it even more +threatPenalty = 2 # extra cost for walking through an enemy turret's covered tiles + +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]) + + +# SENTINEL GEOMETRY, measured on 2.3.4 with a probe bot (do not assume it): +# gunner cardinal 3, diagonal 2 +# sentinel cardinal 5, diagonal 4 +# The seat lists are NOT interchangeable - a gunner seat is not a sentinel seat. +sentinelLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + sentinelLines.append([_dx, _dy, 5, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + sentinelLines.append([_dx, _dy, 4, _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 = ['180', 'flipX', 'flipY'] # '180' assumed most likely, checked first + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + self.constrainedHome = None + self.constrainedFarCorner = None + + 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 = [[16384 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[16384, '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 16384 + return openCost + + def tileCostSafe(self, ct: Controller, tile: Position, enemyThreatened) -> int | None: + stepCost = self.tileCost(ct, tile) + if stepCost is not None and (tile.x, tile.y) in enemyThreatened: + return stepCost + threatPenalty + return stepCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * (gunnerCost + threatPenalty) + 8 + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 16384 + + 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.tileCostSafe(ct, nextTile, enemyThreatened) + 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 16384 + + 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 [conveyorGunnerCost, 'working'] # can be routed through/destroyed, but discouraged + 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) * (conveyorGunnerCost + 24) + 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 16384 + + 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): + if not self.inMoveZone(curEnd): + return + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 16383 + 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 + elif posDist == bestDist and bestNextDir is not None and not self.createsLoop(curEnd, d): + if myLoc.distance_squared(nextPos) < myLoc.distance_squared(curEnd.add(bestNextDir)): + 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) and self.inMoveZone(myLoc.add(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) and self.inMoveZone(newLoc): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def setMoveZone(self, home: Position, farCorner: Position): + self.constrainedHome = home + self.constrainedFarCorner = farCorner + + def clearMoveZone(self): + self.constrainedHome = None + self.constrainedFarCorner = None + + def inMoveZone(self, pos: Position) -> bool: + if self.constrainedHome is None or self.constrainedFarCorner is None: + return True + return (pos.distance_squared(self.constrainedHome) <= 15 and + pos.distance_squared(self.constrainedFarCorner) <= 32) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if not self.inMoveZone(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 = 16384 + 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) and self.inMoveZone(myLoc.add(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) and self.inMoveZone(nextPos): + 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 sentinelSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a SENTINEL could stand and hit target. + + Same walk as gunnerSpots but with the sentinel's longer reach (5/4 + against 3/2), which is why a sentinel needs two fewer tiles of travel + to threaten a core. + """ + spots = [] + for dx, dy, maxK, d in sentinelLines: + 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_v31_sentinel_ammo/main.py b/bots/exp_v31_sentinel_ammo/main.py new file mode 100644 index 0000000..a74a995 --- /dev/null +++ b/bots/exp_v31_sentinel_ammo/main.py @@ -0,0 +1,784 @@ +"""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 * + +# SENTINEL RUSH — the 2.3.4 rebalance inverts the unit economy. +# gunner sentinel +# dmg/turn 7.0 9.0 +# dmg/ammo 1.75 1.80 +# dmg/Ti 0.35 0.60 <- 71% better per titanium +# HP 25 40 +# reach card 3/diag 2 card 5/diag 4 <- TWO fewer tiles to travel +# Gunners were nerfed on four axes at once (cost 10->20, damage 10->7, ammo +# 2->4, HP 40->25) while sentinels were buffed (cooldown 3->2, HP 30->40). +# The reach matters most here: Phase 0 measured our first gun as TRAVEL-bound, +# and a sentinel threatens the core from 5 tiles instead of 3. +# Attack seats now use sentinel geometry and build sentinels. Defence is left +# on gunners deliberately - one change at a time. + + +# 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.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) + # SENTINEL AMMO. The pool was capped at 28 (the comment saying 50 is + # stale), which was sized for 4-ammo gunners: 28/4 = 7 shots available + # per turn. A sentinel shot costs 10, so that cap lets at most TWO + # sentinels fire in a turn. exp_v31_sentinel built 9.2 of them and + # starved seven at 30 Ti each, and gated 21% (kills 17-241) - the units + # were fine, they had nothing to shoot with. + # 2.3.4 also raised the cost of a 500 HP core from 100 ammo to ~280. + # Conversion is 1:1 and once per turn, so holding titanium instead of + # ammo buys nothing; keep a working reserve and bank the rest as ammo. + AMMO_TARGET = 140 + convertAmount = min(AMMO_TARGET - globalAmmo, globalTitanium - 40) + 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) + self.setupDefendClamp(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) + else: # economy (defenders 4/6 included, clamped to their home radius) + # 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 setupDefendClamp(self, ct: Controller): + if self.mapPf.myNum != 4 and self.mapPf.myNum != 6: + self.mapPf.clearMoveZone() + return + home = self.getDefendHome(ct) + if home is None: + self.mapPf.clearMoveZone() + return + self.mapPf.setMoveZone(home, self.farCoreCorner()) + + def farCoreCorner(self) -> Position: + core = self.mapPf.teamCore + center = Position(self.mapW // 2, self.mapH // 2) + corners = [core, core.add(Direction.EAST), core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST)] + return max(corners, key=lambda corner: corner.distance_squared(center)) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + coreBySym = self.mapPf.allEnemyCore + if '180' in coreBySym: + # 180 rotational symmetry is the default assumption - commit most + # attackers there first, but keep a couple checking the alternates + # so a disproof of 180 doesn't leave us starting from zero + candidates = [coreBySym['180']] * 3 + for sym in ('flipX', 'flipY'): + if sym in coreBySym: + candidates.append(coreBySym[sym]) + else: + candidates = list(coreBySym.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_sentinel(gunnerSpot, gunnerDir): + ct.build_sentinel(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.sentinelSpots(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) + dx, dy = bDir.delta() + maxK = 3 if bDir 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 + covered.add((x, y)) + 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 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 not self.mapPf.inMoveZone(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 + if not self.mapPf.inMoveZone(self.routeTarget): + self.clearCommittedRoute() + 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: + if ct.get_entity_type(targetId) == EntityType.CONVEYOR: + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + continue + if ct.get_entity_type(targetId) in (EntityType.BARRIER, EntityType.GUNNER): + # our own blocker at the frontier - hand off to routeConveyor + # below, which already knows how to destroy it and rebuild + break + 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) and self.mapPf.inMoveZone(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) and self.mapPf.inMoveZone(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 not self.mapPf.inMoveZone(tile): + continue + 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/exp_v31_sentinel_ammo/mapPathfinding.py b/bots/exp_v31_sentinel_ammo/mapPathfinding.py new file mode 100644 index 0000000..c0f70d5 --- /dev/null +++ b/bots/exp_v31_sentinel_ammo/mapPathfinding.py @@ -0,0 +1,792 @@ +"""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 +conveyorGunnerCost = 30 # conveyor routing can destroy a gunner too, but discourage it even more +threatPenalty = 2 # extra cost for walking through an enemy turret's covered tiles + +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]) + + +# SENTINEL GEOMETRY, measured on 2.3.4 with a probe bot (do not assume it): +# gunner cardinal 3, diagonal 2 +# sentinel cardinal 5, diagonal 4 +# The seat lists are NOT interchangeable - a gunner seat is not a sentinel seat. +sentinelLines = [] +for _d in CARDINALS: + _dx, _dy = _d.delta() + sentinelLines.append([_dx, _dy, 5, _d]) +for _d in [Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST]: + _dx, _dy = _d.delta() + sentinelLines.append([_dx, _dy, 4, _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 = ['180', 'flipX', 'flipY'] # '180' assumed most likely, checked first + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + self.constrainedHome = None + self.constrainedFarCorner = None + + 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 = [[16384 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[16384, '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 16384 + return openCost + + def tileCostSafe(self, ct: Controller, tile: Position, enemyThreatened) -> int | None: + stepCost = self.tileCost(ct, tile) + if stepCost is not None and (tile.x, tile.y) in enemyThreatened: + return stepCost + threatPenalty + return stepCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * (gunnerCost + threatPenalty) + 8 + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 16384 + + 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.tileCostSafe(ct, nextTile, enemyThreatened) + 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 16384 + + 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 [conveyorGunnerCost, 'working'] # can be routed through/destroyed, but discouraged + 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) * (conveyorGunnerCost + 24) + 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 16384 + + 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): + if not self.inMoveZone(curEnd): + return + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 16383 + 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 + elif posDist == bestDist and bestNextDir is not None and not self.createsLoop(curEnd, d): + if myLoc.distance_squared(nextPos) < myLoc.distance_squared(curEnd.add(bestNextDir)): + 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) and self.inMoveZone(myLoc.add(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) and self.inMoveZone(newLoc): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def setMoveZone(self, home: Position, farCorner: Position): + self.constrainedHome = home + self.constrainedFarCorner = farCorner + + def clearMoveZone(self): + self.constrainedHome = None + self.constrainedFarCorner = None + + def inMoveZone(self, pos: Position) -> bool: + if self.constrainedHome is None or self.constrainedFarCorner is None: + return True + return (pos.distance_squared(self.constrainedHome) <= 15 and + pos.distance_squared(self.constrainedFarCorner) <= 32) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if not self.inMoveZone(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 = 16384 + 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) and self.inMoveZone(myLoc.add(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) and self.inMoveZone(nextPos): + 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 sentinelSpots(self, target: Position, mapW, mapH, blocked=True): + """Every [position, facing] a SENTINEL could stand and hit target. + + Same walk as gunnerSpots but with the sentinel's longer reach (5/4 + against 3/2), which is why a sentinel needs two fewer tiles of travel + to threaten a core. + """ + spots = [] + for dx, dy, maxK, d in sentinelLines: + 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/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/bots/live-v22-control/main.py b/bots/live-v22-control/main.py new file mode 100644 index 0000000..a5c2a2d --- /dev/null +++ b/bots/live-v22-control/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/live-v22-control/mapPathfinding.py b/bots/live-v22-control/mapPathfinding.py new file mode 100644 index 0000000..640e9a7 --- /dev/null +++ b/bots/live-v22-control/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/live-v29-control/main.py b/bots/live-v29-control/main.py new file mode 100644 index 0000000..f7769da --- /dev/null +++ b/bots/live-v29-control/main.py @@ -0,0 +1,735 @@ +"""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.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(28 - globalAmmo, globalTitanium - 16) + 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: + coreBySym = self.mapPf.allEnemyCore + if '180' in coreBySym: + # 180 rotational symmetry is the default assumption - commit most + # attackers there first, but keep a couple checking the alternates + # so a disproof of 180 doesn't leave us starting from zero + candidates = [coreBySym['180']] * 3 + for sym in ('flipX', 'flipY'): + if sym in coreBySym: + candidates.append(coreBySym[sym]) + else: + candidates = list(coreBySym.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) + dx, dy = bDir.delta() + maxK = 3 if bDir 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 + covered.add((x, y)) + 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 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/live-v29-control/mapPathfinding.py b/bots/live-v29-control/mapPathfinding.py new file mode 100644 index 0000000..d7bc966 --- /dev/null +++ b/bots/live-v29-control/mapPathfinding.py @@ -0,0 +1,739 @@ +"""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 +conveyorGunnerCost = 30 # conveyor routing can destroy a gunner too, but discourage it even more +threatPenalty = 2 # extra cost for walking through an enemy turret's covered tiles + +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 = ['180', 'flipX', 'flipY'] # '180' assumed most likely, checked first + 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 tileCostSafe(self, ct: Controller, tile: Position, enemyThreatened) -> int | None: + stepCost = self.tileCost(ct, tile) + if stepCost is not None and (tile.x, tile.y) in enemyThreatened: + return stepCost + threatPenalty + return stepCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * (gunnerCost + threatPenalty) + 8 + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + + 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.tileCostSafe(ct, nextTile, enemyThreatened) + 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 [conveyorGunnerCost, 'working'] # can be routed through/destroyed, but discouraged + 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 + elif posDist == bestDist and not self.createsLoop(curEnd, d): + if myLoc.distance_squared(nextPos) < myLoc.distance_squared(curEnd.add(bestNextDir)): + 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-v30-control/main.py b/bots/live-v30-control/main.py new file mode 100644 index 0000000..38423fe --- /dev/null +++ b/bots/live-v30-control/main.py @@ -0,0 +1,759 @@ +"""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.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(28 - globalAmmo, globalTitanium - 16) + 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) + self.setupDefendClamp(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) + else: # economy (defenders 4/6 included, clamped to their home radius) + # 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 setupDefendClamp(self, ct: Controller): + if self.mapPf.myNum != 4 and self.mapPf.myNum != 6: + self.mapPf.clearMoveZone() + return + home = self.getDefendHome(ct) + if home is None: + self.mapPf.clearMoveZone() + return + self.mapPf.setMoveZone(home, self.farCoreCorner()) + + def farCoreCorner(self) -> Position: + core = self.mapPf.teamCore + center = Position(self.mapW // 2, self.mapH // 2) + corners = [core, core.add(Direction.EAST), core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST)] + return max(corners, key=lambda corner: corner.distance_squared(center)) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + coreBySym = self.mapPf.allEnemyCore + if '180' in coreBySym: + # 180 rotational symmetry is the default assumption - commit most + # attackers there first, but keep a couple checking the alternates + # so a disproof of 180 doesn't leave us starting from zero + candidates = [coreBySym['180']] * 3 + for sym in ('flipX', 'flipY'): + if sym in coreBySym: + candidates.append(coreBySym[sym]) + else: + candidates = list(coreBySym.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) + dx, dy = bDir.delta() + maxK = 3 if bDir 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 + covered.add((x, y)) + 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 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 not self.mapPf.inMoveZone(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 + if not self.mapPf.inMoveZone(self.routeTarget): + self.clearCommittedRoute() + 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: + if ct.get_entity_type(targetId) == EntityType.CONVEYOR: + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + continue + if ct.get_entity_type(targetId) in (EntityType.BARRIER, EntityType.GUNNER): + # our own blocker at the frontier - hand off to routeConveyor + # below, which already knows how to destroy it and rebuild + break + 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) and self.mapPf.inMoveZone(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) and self.mapPf.inMoveZone(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 not self.mapPf.inMoveZone(tile): + continue + 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/live-v30-control/mapPathfinding.py b/bots/live-v30-control/mapPathfinding.py new file mode 100644 index 0000000..5a6fda5 --- /dev/null +++ b/bots/live-v30-control/mapPathfinding.py @@ -0,0 +1,759 @@ +"""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 +conveyorGunnerCost = 30 # conveyor routing can destroy a gunner too, but discourage it even more +threatPenalty = 2 # extra cost for walking through an enemy turret's covered tiles + +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 = ['180', 'flipX', 'flipY'] # '180' assumed most likely, checked first + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + self.constrainedHome = None + self.constrainedFarCorner = None + + 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 = [[16384 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[16384, '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 16384 + return openCost + + def tileCostSafe(self, ct: Controller, tile: Position, enemyThreatened) -> int | None: + stepCost = self.tileCost(ct, tile) + if stepCost is not None and (tile.x, tile.y) in enemyThreatened: + return stepCost + threatPenalty + return stepCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * (gunnerCost + threatPenalty) + 8 + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 16384 + + 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.tileCostSafe(ct, nextTile, enemyThreatened) + 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 16384 + + 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 [conveyorGunnerCost, 'working'] # can be routed through/destroyed, but discouraged + 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) * (conveyorGunnerCost + 24) + 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 16384 + + 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): + if not self.inMoveZone(curEnd): + return + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 16383 + 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 + elif posDist == bestDist and bestNextDir is not None and not self.createsLoop(curEnd, d): + if myLoc.distance_squared(nextPos) < myLoc.distance_squared(curEnd.add(bestNextDir)): + 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) and self.inMoveZone(myLoc.add(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) and self.inMoveZone(newLoc): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def setMoveZone(self, home: Position, farCorner: Position): + self.constrainedHome = home + self.constrainedFarCorner = farCorner + + def clearMoveZone(self): + self.constrainedHome = None + self.constrainedFarCorner = None + + def inMoveZone(self, pos: Position) -> bool: + if self.constrainedHome is None or self.constrainedFarCorner is None: + return True + return (pos.distance_squared(self.constrainedHome) <= 15 and + pos.distance_squared(self.constrainedFarCorner) <= 32) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if not self.inMoveZone(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 = 16384 + 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) and self.inMoveZone(myLoc.add(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) and self.inMoveZone(nextPos): + 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-v30-twin/main.py b/bots/live-v30-twin/main.py new file mode 100644 index 0000000..38423fe --- /dev/null +++ b/bots/live-v30-twin/main.py @@ -0,0 +1,759 @@ +"""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.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(28 - globalAmmo, globalTitanium - 16) + 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) + self.setupDefendClamp(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) + else: # economy (defenders 4/6 included, clamped to their home radius) + # 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 setupDefendClamp(self, ct: Controller): + if self.mapPf.myNum != 4 and self.mapPf.myNum != 6: + self.mapPf.clearMoveZone() + return + home = self.getDefendHome(ct) + if home is None: + self.mapPf.clearMoveZone() + return + self.mapPf.setMoveZone(home, self.farCoreCorner()) + + def farCoreCorner(self) -> Position: + core = self.mapPf.teamCore + center = Position(self.mapW // 2, self.mapH // 2) + corners = [core, core.add(Direction.EAST), core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST)] + return max(corners, key=lambda corner: corner.distance_squared(center)) + + def runAttack(self, ct: Controller): + myLoc = ct.get_position() + if self.mapPf.enemyCorePos is None: + coreBySym = self.mapPf.allEnemyCore + if '180' in coreBySym: + # 180 rotational symmetry is the default assumption - commit most + # attackers there first, but keep a couple checking the alternates + # so a disproof of 180 doesn't leave us starting from zero + candidates = [coreBySym['180']] * 3 + for sym in ('flipX', 'flipY'): + if sym in coreBySym: + candidates.append(coreBySym[sym]) + else: + candidates = list(coreBySym.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) + dx, dy = bDir.delta() + maxK = 3 if bDir 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 + covered.add((x, y)) + 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 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 not self.mapPf.inMoveZone(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 + if not self.mapPf.inMoveZone(self.routeTarget): + self.clearCommittedRoute() + 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: + if ct.get_entity_type(targetId) == EntityType.CONVEYOR: + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + continue + if ct.get_entity_type(targetId) in (EntityType.BARRIER, EntityType.GUNNER): + # our own blocker at the frontier - hand off to routeConveyor + # below, which already knows how to destroy it and rebuild + break + 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) and self.mapPf.inMoveZone(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) and self.mapPf.inMoveZone(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 not self.mapPf.inMoveZone(tile): + continue + 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/live-v30-twin/mapPathfinding.py b/bots/live-v30-twin/mapPathfinding.py new file mode 100644 index 0000000..5a6fda5 --- /dev/null +++ b/bots/live-v30-twin/mapPathfinding.py @@ -0,0 +1,759 @@ +"""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 +conveyorGunnerCost = 30 # conveyor routing can destroy a gunner too, but discourage it even more +threatPenalty = 2 # extra cost for walking through an enemy turret's covered tiles + +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 = ['180', 'flipX', 'flipY'] # '180' assumed most likely, checked first + self.allEnemyCore = {} + self.enemyCorePos = None + self.mapSymmetry = None + self.enemyThreatCache = set() + self.enemyThreatRound = -1 + self.constrainedHome = None + self.constrainedFarCorner = None + + 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 = [[16384 for _ in range(self.mapH)] for _ in range(self.mapW)] + if self.conveyorMap is None: + self.conveyorMap = [[[16384, '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 16384 + return openCost + + def tileCostSafe(self, ct: Controller, tile: Position, enemyThreatened) -> int | None: + stepCost = self.tileCost(ct, tile) + if stepCost is not None and (tile.x, tile.y) in enemyThreatened: + return stepCost + threatPenalty + return stepCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * (gunnerCost + threatPenalty) + 8 + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + + for x in range(w): + for y in range(h): + self.distMap[x][y] = 16384 + + 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.tileCostSafe(ct, nextTile, enemyThreatened) + 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 16384 + + 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 [conveyorGunnerCost, 'working'] # can be routed through/destroyed, but discouraged + 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) * (conveyorGunnerCost + 24) + 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 16384 + + 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): + if not self.inMoveZone(curEnd): + return + myLoc = ct.get_position() + self.fillConveyorDistTable(ct, curEnd) + bestDist = 16383 + 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 + elif posDist == bestDist and bestNextDir is not None and not self.createsLoop(curEnd, d): + if myLoc.distance_squared(nextPos) < myLoc.distance_squared(curEnd.add(bestNextDir)): + 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) and self.inMoveZone(myLoc.add(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) and self.inMoveZone(newLoc): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def setMoveZone(self, home: Position, farCorner: Position): + self.constrainedHome = home + self.constrainedFarCorner = farCorner + + def clearMoveZone(self): + self.constrainedHome = None + self.constrainedFarCorner = None + + def inMoveZone(self, pos: Position) -> bool: + if self.constrainedHome is None or self.constrainedFarCorner is None: + return True + return (pos.distance_squared(self.constrainedHome) <= 15 and + pos.distance_squared(self.constrainedFarCorner) <= 32) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if not self.inMoveZone(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 = 16384 + 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) and self.inMoveZone(myLoc.add(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) and self.inMoveZone(nextPos): + 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-v37-control/main.py b/bots/live-v37-control/main.py new file mode 100644 index 0000000..db72ba5 --- /dev/null +++ b/bots/live-v37-control/main.py @@ -0,0 +1,846 @@ +"""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] +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 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.routeTarget = None + self.routeBestDistance = None + self.routeLastProgressRound = 0 + self.knownConnectedConveyors = {} + 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) + self.setupDefendClamp(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) + else: # economy / defending - both build the same way + # 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 setupDefendClamp(self, ct: Controller): + if self.mapPf.myNum != 4 and self.mapPf.myNum != 6: + self.mapPf.clearMoveZone() + return + home = self.getDefendHome(ct) + if home is None: + self.mapPf.clearMoveZone() + return + self.mapPf.setMoveZone(home, self.farCoreCorner()) + + def farCoreCorner(self) -> Position: + core = self.mapPf.teamCore + center = Position(self.mapW // 2, self.mapH // 2) + corners = [core, core.add(Direction.EAST), core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST)] + return max(corners, key=lambda corner: corner.distance_squared(center)) + + 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() >= 84: + 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 + # Can't place any more (no open seat or below titanium threshold): + # divert to heal a damaged team gunner before marching on. + if self.healTeamGunners(ct): + 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 = 20 if bestIsCoreDefense else 80 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def cornerShields(self, ct: Controller) -> bool: + """SENTINEL-LINE SEAL (from v33/v34, ic3d's observation; v36 shipped + without it and the 2-3-vs-Pantheon result came FROM it). Every + sentinel line into the core passes either the diagonal corner tile + (diagonal lines) or a cardinal lane tile at distance 2 (straight + lines). 12 barriers, 36 Ti, mirror-free (51%/49% measured). The d1 + ring stays open for spawns and heals; own barriers are passable. + fjord fix: approach shield tiles even when OUT of vision — the old + is_in_vision gate meant a tile nobody stood near never got built.""" + core = None + v = ct.read_store(7) + if v: + core = Position(v // 32, v % 32) + if core is None: + return False + myLoc = ct.get_position() + if abs(myLoc.x - core.x) + abs(myLoc.y - core.y) > 8: + return False + if ct.get_global_resources() < 25: + return False + shields = [Position(core.x - 1, core.y - 1), + Position(core.x + 2, core.y - 1), + Position(core.x - 1, core.y + 2), + Position(core.x + 2, core.y + 2), + Position(core.x, core.y - 2), + Position(core.x + 1, core.y - 2), + Position(core.x, core.y + 3), + Position(core.x + 1, core.y + 3), + Position(core.x - 2, core.y), + Position(core.x - 2, core.y + 1), + Position(core.x + 3, core.y), + Position(core.x + 3, core.y + 1)] + for sp in shields: + if not (0 <= sp.x < self.mapW and 0 <= sp.y < self.mapH): + continue + seen = False + try: + seen = ct.is_in_vision(sp) + if seen: + if ct.get_tile_building_id(sp) is not None: + continue + if ct.get_tile_env(sp) != Environment.EMPTY: + continue + except Exception: + continue + d = abs(myLoc.x - sp.x) + abs(myLoc.y - sp.y) + if d == 1: + if ct.can_act() and ct.can_build_barrier(sp): + ct.build_barrier(sp) + return True + if seen: + continue + return False + if d <= 5 and ct.get_move_cooldown() == 0: + self.mapPf.moveTo(ct, sp) + return True + return False + + def runDefend(self, ct: Controller): + if self.cornerShields(ct): + return + 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 healDamagedNonCore(self, ct: Controller) -> bool: + """Walk to and repair the nearest damaged friendly non-core building. + Unlike healAttackedNonCore, no enemy line-of-fire requirement - any + damaged conveyor/harvester/barrier/turret qualifies. Returns False + when no such tile is visible, so the caller can continue with + routing/harvesting.""" + myLoc = ct.get_position() + myTeam = ct.get_team() + best = None + bestKey = None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.CORE: + continue + if bType not in (EntityType.GUNNER, EntityType.SENTINEL, + EntityType.BARRIER, EntityType.CONVEYOR, + EntityType.HARVESTER): + continue + if ct.get_hp(b) >= ct.get_max_hp(b): + continue + bPos = ct.get_position(b) + key = myLoc.distance_squared(bPos) + if bestKey is None or key < bestKey: + bestKey = key + best = bPos + if best is None: + return False + if myLoc.distance_squared(best) == 1 and ct.can_heal(best): + ct.heal(best) + return True + self.mapPf.moveTo(ct, best) + return True + + def healTeamGunners(self, ct: Controller) -> bool: + """Walk to and repair the most damaged friendly gunner/sentinel in + vision. Used by attack bots when they cannot place any more turrets + (no open seat, or titanium below threshold). Returns False when no + visible damaged friendly turret exists, so the caller can fall back + to its default action.""" + myLoc = ct.get_position() + myTeam = ct.get_team() + best = None + bestKey = None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + if ct.get_entity_type(b) not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + if ct.get_hp(b) >= ct.get_max_hp(b): + continue + bPos = ct.get_position(b) + missing = ct.get_max_hp(b) - ct.get_hp(b) + key = (-missing, myLoc.distance_squared(bPos)) + if bestKey is None or key < bestKey: + bestKey = key + best = bPos + if best is None: + return False + if myLoc.distance_squared(best) == 1 and ct.can_heal(best): + ct.heal(best) + return True + self.mapPf.moveTo(ct, best) + return True + + 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 + if visibleDamagedCore: # heal the core whenever it is damaged, even with no enemy gunners in sight + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.healDamagedNonCore(ct): + 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 observeConnectedConveyors(self, ct: Controller, myTeam): + convDirs, convLoop = self.mapPf.classifyConveyors(ct, myTeam) + for key, data in convDirs.items(): + if not convLoop.get(key, False): + self.knownConnectedConveyors[key] = (data[0], data[1]) + return convDirs, convLoop + + def repairCandidate(self, ct: Controller, myLoc, myTeam, convDirs, convLoop): + """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 not convLoop.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, convLoop = self.observeConnectedConveyors(ct, myTeam) + candidate = self.repairCandidate(ct, myLoc, myTeam, convDirs, convLoop) + 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 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: + if ct.get_entity_type(targetId) == EntityType.CONVEYOR: + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + continue + if ct.get_entity_type(targetId) in (EntityType.BARRIER, EntityType.GUNNER): + # our own blocker at the frontier - hand off to routeConveyor + # below, which already knows how to destroy it and rebuild + break + 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) and self.mapPf.inMoveZone(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) and self.mapPf.inMoveZone(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 not self.mapPf.inMoveZone(tile): + continue + 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/live-v37-control/mapPathfinding.py b/bots/live-v37-control/mapPathfinding.py new file mode 100644 index 0000000..b65a6e6 --- /dev/null +++ b/bots/live-v37-control/mapPathfinding.py @@ -0,0 +1,694 @@ +"""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.constrainedHome = None + self.constrainedFarCorner = None + 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) * (gunnerCost + 24) + 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): + if not self.inMoveZone(curEnd): + return + 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 + elif posDist == bestDist and bestNextDir is not None and not self.createsLoop(curEnd, d): + if myLoc.distance_squared(nextPos) < myLoc.distance_squared(curEnd.add(bestNextDir)): + 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) and self.inMoveZone(myLoc.add(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) and self.inMoveZone(newLoc): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def setMoveZone(self, home: Position, farCorner: Position): + self.constrainedHome = home + self.constrainedFarCorner = farCorner + + def clearMoveZone(self): + self.constrainedHome = None + self.constrainedFarCorner = None + + def inMoveZone(self, pos: Position) -> bool: + if self.constrainedHome is None or self.constrainedFarCorner is None: + return True + return (pos.distance_squared(self.constrainedHome) <= 15 and + pos.distance_squared(self.constrainedFarCorner) <= 32) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if not self.inMoveZone(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() > 110: + 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) and self.inMoveZone(myLoc.add(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) and self.inMoveZone(nextPos): + 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-v37-twin/main.py b/bots/live-v37-twin/main.py new file mode 100644 index 0000000..db72ba5 --- /dev/null +++ b/bots/live-v37-twin/main.py @@ -0,0 +1,846 @@ +"""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] +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 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.routeTarget = None + self.routeBestDistance = None + self.routeLastProgressRound = 0 + self.knownConnectedConveyors = {} + 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) + self.setupDefendClamp(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) + else: # economy / defending - both build the same way + # 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 setupDefendClamp(self, ct: Controller): + if self.mapPf.myNum != 4 and self.mapPf.myNum != 6: + self.mapPf.clearMoveZone() + return + home = self.getDefendHome(ct) + if home is None: + self.mapPf.clearMoveZone() + return + self.mapPf.setMoveZone(home, self.farCoreCorner()) + + def farCoreCorner(self) -> Position: + core = self.mapPf.teamCore + center = Position(self.mapW // 2, self.mapH // 2) + corners = [core, core.add(Direction.EAST), core.add(Direction.SOUTH), + core.add(Direction.SOUTH).add(Direction.EAST)] + return max(corners, key=lambda corner: corner.distance_squared(center)) + + 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() >= 84: + 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 + # Can't place any more (no open seat or below titanium threshold): + # divert to heal a damaged team gunner before marching on. + if self.healTeamGunners(ct): + 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 = 20 if bestIsCoreDefense else 80 + if ct.get_global_resources() > floor and ct.can_rotate(bestDir): + ct.rotate(bestDir) + + def cornerShields(self, ct: Controller) -> bool: + """SENTINEL-LINE SEAL (from v33/v34, ic3d's observation; v36 shipped + without it and the 2-3-vs-Pantheon result came FROM it). Every + sentinel line into the core passes either the diagonal corner tile + (diagonal lines) or a cardinal lane tile at distance 2 (straight + lines). 12 barriers, 36 Ti, mirror-free (51%/49% measured). The d1 + ring stays open for spawns and heals; own barriers are passable. + fjord fix: approach shield tiles even when OUT of vision — the old + is_in_vision gate meant a tile nobody stood near never got built.""" + core = None + v = ct.read_store(7) + if v: + core = Position(v // 32, v % 32) + if core is None: + return False + myLoc = ct.get_position() + if abs(myLoc.x - core.x) + abs(myLoc.y - core.y) > 8: + return False + if ct.get_global_resources() < 25: + return False + shields = [Position(core.x - 1, core.y - 1), + Position(core.x + 2, core.y - 1), + Position(core.x - 1, core.y + 2), + Position(core.x + 2, core.y + 2), + Position(core.x, core.y - 2), + Position(core.x + 1, core.y - 2), + Position(core.x, core.y + 3), + Position(core.x + 1, core.y + 3), + Position(core.x - 2, core.y), + Position(core.x - 2, core.y + 1), + Position(core.x + 3, core.y), + Position(core.x + 3, core.y + 1)] + for sp in shields: + if not (0 <= sp.x < self.mapW and 0 <= sp.y < self.mapH): + continue + seen = False + try: + seen = ct.is_in_vision(sp) + if seen: + if ct.get_tile_building_id(sp) is not None: + continue + if ct.get_tile_env(sp) != Environment.EMPTY: + continue + except Exception: + continue + d = abs(myLoc.x - sp.x) + abs(myLoc.y - sp.y) + if d == 1: + if ct.can_act() and ct.can_build_barrier(sp): + ct.build_barrier(sp) + return True + if seen: + continue + return False + if d <= 5 and ct.get_move_cooldown() == 0: + self.mapPf.moveTo(ct, sp) + return True + return False + + def runDefend(self, ct: Controller): + if self.cornerShields(ct): + return + 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 healDamagedNonCore(self, ct: Controller) -> bool: + """Walk to and repair the nearest damaged friendly non-core building. + Unlike healAttackedNonCore, no enemy line-of-fire requirement - any + damaged conveyor/harvester/barrier/turret qualifies. Returns False + when no such tile is visible, so the caller can continue with + routing/harvesting.""" + myLoc = ct.get_position() + myTeam = ct.get_team() + best = None + bestKey = None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + bType = ct.get_entity_type(b) + if bType == EntityType.CORE: + continue + if bType not in (EntityType.GUNNER, EntityType.SENTINEL, + EntityType.BARRIER, EntityType.CONVEYOR, + EntityType.HARVESTER): + continue + if ct.get_hp(b) >= ct.get_max_hp(b): + continue + bPos = ct.get_position(b) + key = myLoc.distance_squared(bPos) + if bestKey is None or key < bestKey: + bestKey = key + best = bPos + if best is None: + return False + if myLoc.distance_squared(best) == 1 and ct.can_heal(best): + ct.heal(best) + return True + self.mapPf.moveTo(ct, best) + return True + + def healTeamGunners(self, ct: Controller) -> bool: + """Walk to and repair the most damaged friendly gunner/sentinel in + vision. Used by attack bots when they cannot place any more turrets + (no open seat, or titanium below threshold). Returns False when no + visible damaged friendly turret exists, so the caller can fall back + to its default action.""" + myLoc = ct.get_position() + myTeam = ct.get_team() + best = None + bestKey = None + for b in ct.get_nearby_buildings(): + if ct.get_team(b) != myTeam: + continue + if ct.get_entity_type(b) not in (EntityType.GUNNER, EntityType.SENTINEL): + continue + if ct.get_hp(b) >= ct.get_max_hp(b): + continue + bPos = ct.get_position(b) + missing = ct.get_max_hp(b) - ct.get_hp(b) + key = (-missing, myLoc.distance_squared(bPos)) + if bestKey is None or key < bestKey: + bestKey = key + best = bPos + if best is None: + return False + if myLoc.distance_squared(best) == 1 and ct.can_heal(best): + ct.heal(best) + return True + self.mapPf.moveTo(ct, best) + return True + + 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 + if visibleDamagedCore: # heal the core whenever it is damaged, even with no enemy gunners in sight + self.healCore(ct, home=self.mapPf.teamCore) + return + if self.healDamagedNonCore(ct): + 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 observeConnectedConveyors(self, ct: Controller, myTeam): + convDirs, convLoop = self.mapPf.classifyConveyors(ct, myTeam) + for key, data in convDirs.items(): + if not convLoop.get(key, False): + self.knownConnectedConveyors[key] = (data[0], data[1]) + return convDirs, convLoop + + def repairCandidate(self, ct: Controller, myLoc, myTeam, convDirs, convLoop): + """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 not convLoop.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, convLoop = self.observeConnectedConveyors(ct, myTeam) + candidate = self.repairCandidate(ct, myLoc, myTeam, convDirs, convLoop) + 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 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: + if ct.get_entity_type(targetId) == EntityType.CONVEYOR: + self.routeTarget = target.add(ct.get_direction(targetId)) + self.routeBestDistance = None + self.routeLastProgressRound = currentRound + continue + if ct.get_entity_type(targetId) in (EntityType.BARRIER, EntityType.GUNNER): + # our own blocker at the frontier - hand off to routeConveyor + # below, which already knows how to destroy it and rebuild + break + 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) and self.mapPf.inMoveZone(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) and self.mapPf.inMoveZone(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 not self.mapPf.inMoveZone(tile): + continue + 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/live-v37-twin/mapPathfinding.py b/bots/live-v37-twin/mapPathfinding.py new file mode 100644 index 0000000..b65a6e6 --- /dev/null +++ b/bots/live-v37-twin/mapPathfinding.py @@ -0,0 +1,694 @@ +"""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.constrainedHome = None + self.constrainedFarCorner = None + 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) * (gunnerCost + 24) + 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): + if not self.inMoveZone(curEnd): + return + 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 + elif posDist == bestDist and bestNextDir is not None and not self.createsLoop(curEnd, d): + if myLoc.distance_squared(nextPos) < myLoc.distance_squared(curEnd.add(bestNextDir)): + 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) and self.inMoveZone(myLoc.add(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) and self.inMoveZone(newLoc): + ct.move(d) + else: + self.moveTo(ct, curEnd) + + def setMoveZone(self, home: Position, farCorner: Position): + self.constrainedHome = home + self.constrainedFarCorner = farCorner + + def clearMoveZone(self): + self.constrainedHome = None + self.constrainedFarCorner = None + + def inMoveZone(self, pos: Position) -> bool: + if self.constrainedHome is None or self.constrainedFarCorner is None: + return True + return (pos.distance_squared(self.constrainedHome) <= 15 and + pos.distance_squared(self.constrainedFarCorner) <= 32) + + def moveTo (self, ct: Controller, target: Position): + myLoc = ct.get_position() + if myLoc == target: + return + if not self.inMoveZone(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() > 110: + 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) and self.inMoveZone(myLoc.add(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) and self.inMoveZone(nextPos): + 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/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/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) 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 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/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/bots/spar_v29_rush/main.py b/bots/spar_v29_rush/main.py new file mode 100644 index 0000000..ee65bf9 --- /dev/null +++ b/bots/spar_v29_rush/main.py @@ -0,0 +1,742 @@ +"""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.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(28 - globalAmmo, globalTitanium - 16) + 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) + # SPARRING PARTNER, NEVER A SUBMISSION. spar_rush is obsolete: v29 beats + # it 97%, because it was built from the v8-era engine. We still have no + # local opponent that kills fast on a COMPETITIVE chassis, which is the + # blind spot that has made every tempo experiment unmeasurable - the + # matchup we actually lose (Pantheon, first gun t7) has no proxy here. + # This is v29 with almost every builder attacking: same pathfinder, same + # seat selection, same engine quality, maximum tempo. Do not submit it. + if self.mapPf.myNum != 2: # attacking (spar profile: all but one) + # 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: + coreBySym = self.mapPf.allEnemyCore + if '180' in coreBySym: + # 180 rotational symmetry is the default assumption - commit most + # attackers there first, but keep a couple checking the alternates + # so a disproof of 180 doesn't leave us starting from zero + candidates = [coreBySym['180']] * 3 + for sym in ('flipX', 'flipY'): + if sym in coreBySym: + candidates.append(coreBySym[sym]) + else: + candidates = list(coreBySym.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) + dx, dy = bDir.delta() + maxK = 3 if bDir 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 + covered.add((x, y)) + 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 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/spar_v29_rush/mapPathfinding.py b/bots/spar_v29_rush/mapPathfinding.py new file mode 100644 index 0000000..d7bc966 --- /dev/null +++ b/bots/spar_v29_rush/mapPathfinding.py @@ -0,0 +1,739 @@ +"""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 +conveyorGunnerCost = 30 # conveyor routing can destroy a gunner too, but discourage it even more +threatPenalty = 2 # extra cost for walking through an enemy turret's covered tiles + +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 = ['180', 'flipX', 'flipY'] # '180' assumed most likely, checked first + 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 tileCostSafe(self, ct: Controller, tile: Position, enemyThreatened) -> int | None: + stepCost = self.tileCost(ct, tile) + if stepCost is not None and (tile.x, tile.y) in enemyThreatened: + return stepCost + threatPenalty + return stepCost + + def fillInDistTable (self, ct: Controller, targetLoc: Position): + w, h = self.mapW, self.mapH + maxCost = (w + h) * (gunnerCost + threatPenalty) + 8 + enemyThreatened = self.enemyTurretThreatenedTiles(ct) + + 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.tileCostSafe(ct, nextTile, enemyThreatened) + 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 [conveyorGunnerCost, 'working'] # can be routed through/destroyed, but discouraged + 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 + elif posDist == bestDist and not self.createsLoop(curEnd, d): + if myLoc.distance_squared(nextPos) < myLoc.distance_squared(curEnd.add(bestNextDir)): + 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/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 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)}") diff --git a/conveyor_audit.py b/conveyor_audit.py new file mode 100644 index 0000000..cce917d --- /dev/null +++ b/conveyor_audit.py @@ -0,0 +1,185 @@ +"""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) + + 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: + continue + delta = DIRECTION_DELTA.get(record["facing"]) + 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 + return { + "conveyors": len(conveyors), + "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), + "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/gate.py b/gate.py index d6979cc..d568898 100644 --- a/gate.py +++ b/gate.py @@ -2,40 +2,70 @@ 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=generalist-v3] [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 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 import datetime as dt +import hashlib +import json import os -import re import shutil import sys +import tempfile +from math import sqrt 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 +# 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: sys.exit("candidate and opponent are the same bot name - the winner line can't be " @@ -46,39 +76,179 @@ 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", + "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): - # 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)) + +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. + with tempfile.TemporaryDirectory(prefix="fcode-gate-") as temp_dir: + replay_path = Path(temp_dir) / "game.replay26" + out = run( + [ + "fcode", + "run", + side_a, + side_b, + 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, side_a, side_b, seed, "draw", "unknown", 0, { + column: 0 for column in METRIC_COLUMNS + } + + winner_side = result.get("winner") + 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 = { + "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, + side_a, + side_b, + 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, 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]) + CAND, BASE, m, seed, side_a, winner, cond, turn, ENGINE] + + [metrics[column] for column in METRIC_COLUMNS]) if winner == CAND: w += 1 if "destroyed" in cond.lower(): @@ -88,29 +258,103 @@ 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} {side_a} as A -> {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, 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) + 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) +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/generalist-v2.zip b/generalist-v2.zip new file mode 100644 index 0000000..f5db403 Binary files /dev/null and b/generalist-v2.zip differ diff --git a/generalist-v3.zip b/generalist-v3.zip new file mode 100644 index 0000000..b7519fb Binary files /dev/null and b/generalist-v3.zip differ 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/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/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/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/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..022f18b --- /dev/null +++ b/live_replay_audit.py @@ -0,0 +1,403 @@ +"""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_home_turret_build_rounds = defaultdict(list) + 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 + 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 + 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 + } + 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, + "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), + "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 + ), + "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], + "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], + "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/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 0000000..7c643fb Binary files /dev/null and b/meta-generalist-v1.zip differ diff --git a/meta_generalist_v1_results.json b/meta_generalist_v1_results.json new file mode 100644 index 0000000..beacfd1 --- /dev/null +++ b/meta_generalist_v1_results.json @@ -0,0 +1,40 @@ +{ + "bot": "meta-generalist-v1", + "status": "local frozen candidate; not uploaded, queued, active, or copied to root", + "engine": "fcode 2.3.2.dev29 local; production engine/session still needs fresh verification", + "parent": "bots/exp_trans_40 (production-v8 source lineage)", + "change": "gunners target enemy builders on the engine's separate builder layer and score them at the existing weight of 2", + "gates": [ + { + "opponent": "exp_trans_40", + "games": 336, + "wins": 200, + "core_kills_for": 181, + "core_kills_against": 127, + "candidate_titanium_collected_mean": 819.1, + "opponent_titanium_collected_mean": 718.0 + }, + { + "opponent": "spar_rush", + "games": 168, + "wins": 88, + "core_kills_for": 81, + "core_kills_against": 64, + "control": "exp_trans_40 won 92/168 with kills 85-64; treat as neutral/slightly negative" + }, + { + "opponent": "oogbest-v6", + "games": 168, + "wins": 86, + "core_kills_for": 83, + "core_kills_against": 67 + } + ], + "sha256": { + "main.py": "A9EC959ECAE2E0B11EDB3DAA89AD3A3CD480196C903FEB543F8059B76C2D965C", + "initialSpawning.py": "E6D48213A505729ED98BBBC1B55623484BC83B202B2E78D4FA9E929686D40BB6", + "mapPathfinding.py": "971A102A26E8792E6B0FED6FE8F7710641FD1D03D299322F97423A43342AE4A6", + "symmetry.py": "8C4AB5843AB90F8C0907E261E2049B75588BC88BFB8CEEA2CC236E3CD10CD9A1", + "meta-generalist-v1.zip": "4F94E7301B99EC34283C82F6FAFDE7C61F03135C9702F4792AE3D3BC6642EE07" + } +} diff --git a/oarena.toml b/oarena.toml new file mode 100644 index 0000000..94d99fe --- /dev/null +++ b/oarena.toml @@ -0,0 +1,33 @@ +# oarena — local bot league for the Florent Code League (fcode). +# State lives in ./.oarena/ ; delete that directory to start over. + +bots_dir = "bots" # every sub-directory holding main.py is a bot +maps_dir = "maps" # official *.map26 maps +extra_maps_dir = "more_maps" # generated/local *.map26 maps +# state_dir = "/absolute/shared/.oarena" # optional shared league state +# deactivate_missing_bots = false # partial bot catalogs must opt out +# bot_sync_mode = "verify-only" # verify hashes; never rewrite shared bot rows + +workers = 0 # parallel games; 0 = one per CPU core +tle_ms = 50 # profiling-friendly local per-turn limit; platform uses 10 (0 = off) +game_timeout_s = 300 # hard wall-clock kill for a single game + +# Which maps a match plays when you don't pass -m. Empty = every map in both directories. +maps = [] +mirror = true # play every (map, seed) twice with sides swapped +seed_policy = "random" # random | fixed +seed = 1 # base seed when seed_policy = "fixed" + +coinflip_is_draw = true # the engine's coinflip tiebreak carries no skill signal +python_dont_write_bytecode = false # pass PYTHONDONTWRITEBYTECODE=1 to game workers + +replay_budget_mb = 512 # oldest replays are pruned past this; "unlimited" disables pruning +sigma_reinflate = 0.5 # on source change, sigma is lifted back to at least 0.5 * sigma0 +ui_theme = "dark" # dashboard theme; saved by the UI and shared by every server port + +[trueskill] +mu = 25.0 +sigma = 8.333333333333334 +tau = 0.08333333333333333 +draw_prob = 0.02 +# beta = 4.166666666666667 # defaults to sigma / 2 diff --git a/recall2.zip b/recall2.zip new file mode 100644 index 0000000..b9fec2d Binary files /dev/null and b/recall2.zip differ diff --git a/replay.replay26 b/replay.replay26 index 9f32fd8..38aa2e0 100644 Binary files a/replay.replay26 and b/replay.replay26 differ diff --git a/siege-on-sight.zip b/siege-on-sight.zip new file mode 100644 index 0000000..2bcff73 Binary files /dev/null and b/siege-on-sight.zip differ 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_conveyor_audit.py b/tests/test_conveyor_audit.py new file mode 100644 index 0000000..490492e --- /dev/null +++ b/tests/test_conveyor_audit.py @@ -0,0 +1,55 @@ +"""Topology classification tests for the offline conveyor audit.""" + +from pathlib import Path +import sys + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from conveyor_audit import topology + + +def record(team, kind, position, facing=None): + return { + "alive": True, + "team": team, + "type": kind, + "position": position, + "facing": facing, + "born": 1, + } + + +def test_cycle_and_backfeed_are_reported_separately(): + entities = { + 1: record(0, "harvester", (4, 4)), + 2: record(0, "conveyor", (3, 4), 3), # east, into harvester + 3: record(0, "conveyor", (5, 5), 3), + 4: record(0, "conveyor", (6, 5), 5), + 5: record(0, "conveyor", (6, 6), 7), + 6: record(0, "conveyor", (5, 6), 1), + } + result = topology((0, 0), entities, 0) + assert result["cycles"] == 1 + assert result["cycle_conveyors"] == 4 + assert result["backfeed_roots"] == 1 + assert result["served"] == 0 + + +def test_connected_chain_has_no_false_cycle(): + entities = { + 1: record(0, "harvester", (4, 0)), + 2: record(0, "conveyor", (3, 0), 7), + 3: record(0, "conveyor", (2, 0), 7), + } + result = topology((0, 0), entities, 0) + assert result["connected"] == 2 + assert result["served"] == 1 + assert result["cycles"] == 0 + assert result["backfeed_roots"] == 0 + + +if __name__ == "__main__": + test_cycle_and_backfeed_are_reported_separately() + test_connected_chain_has_no_false_cycle() 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_dynamic_fronts.py b/tests/test_dynamic_fronts.py new file mode 100644 index 0000000..e606a81 --- /dev/null +++ b/tests/test_dynamic_fronts.py @@ -0,0 +1,78 @@ +"""Deterministic tests for the dynamic multi-front allocator.""" + +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_dynamic_fronts" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("dynamic_fronts", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +def test_pressure_gap_sizes_front_without_consuming_local_economy(): + assert BOT.required_defense_fronts(0, 0, 5) == 0 + assert BOT.required_defense_fronts(1, 0, 5) == 1 + assert BOT.required_defense_fronts(3, 0, 5) == 3 + assert BOT.required_defense_fronts(5, 0, 4) == 2 + assert BOT.required_defense_fronts(3, 2, 5) == 2 + + +def test_fronts_are_distinct_and_siegers_are_last_resort(): + candidates = [ + (10, Position(1, 1)), + (11, Position(8, 8)), + (12, Position(2, 2)), + (13, Position(7, 7)), + (14, Position(4, 4)), + ] + threats = [ + (100, Position(0, 0)), + (101, Position(9, 9)), + (102, Position(5, 5)), + ] + result = BOT.choose_defense_fronts( + candidates, {10, 11}, threats, {100: 0, 101: 0, 102: 0}) + assert len(result) == 3 + assert len({builder for builder, _, _ in result}) == 3 + assert len({threat for _, threat, _ in result}) == 3 + assert {builder for builder, _, _ in result}.isdisjoint({10, 11}) + + +def test_existing_coverage_reduces_assignment_count_and_priority(): + candidates = [ + (1, Position(1, 1)), (2, Position(8, 8)), + (3, Position(5, 5)), (4, Position(4, 4)), + ] + threats = [(20, Position(0, 0)), (21, Position(9, 9))] + result = BOT.choose_defense_fronts( + candidates, set(), threats, {20: 2, 21: 0}) + assert len(result) == 1 + assert result[0][1] == 21 + + +def test_protocol_slots_do_not_overlap_enemy_core(): + assert set(BOT.DEFENDER_ID_SLOTS).isdisjoint( + BOT.DEFENDER_TARGET_SLOTS) + assert BOT.SLOT_ENEMY_CORE not in BOT.DEFENDER_ID_SLOTS + assert BOT.SLOT_ENEMY_CORE not in BOT.DEFENDER_TARGET_SLOTS + assert BOT.SLOT_DEFENSE_COUNT not in BOT.DEFENDER_ID_SLOTS + + +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_pressure_gap_sizes_front_without_consuming_local_economy() + test_fronts_are_distinct_and_siegers_are_last_resort() + test_existing_coverage_reduces_assignment_count_and_priority() + test_protocol_slots_do_not_overlap_enemy_core() + test_no_fingerprints() 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 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_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}" diff --git a/tests/test_late_route_idle.py b/tests/test_late_route_idle.py new file mode 100644 index 0000000..41ad2ef --- /dev/null +++ b/tests/test_late_route_idle.py @@ -0,0 +1,297 @@ +"""Deterministic mechanism tests for exp_late_route_idle.""" + +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_idle" + + +def load_bot(): + sys.path.insert(0, str(BOT_DIR)) + try: + spec = importlib.util.spec_from_file_location( + "late_route_idle_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 + 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("idle late route repair 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_counter_tend.py b/tests/test_meta_counter_tend.py new file mode 100644 index 0000000..f6337c3 --- /dev/null +++ b/tests/test_meta_counter_tend.py @@ -0,0 +1,99 @@ +"""Tests for adjacent maintenance of a live home countergun.""" + +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_counter_tend" +PARENT = ROOT / "bots" / "meta-generalist-v1" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("meta_counter_tend", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +class TendController: + def __init__(self, damaged=True): + self.damaged = damaged + self.healed = None + + def is_in_vision(self, pos): + return False + + def get_position(self, entity_id=None): + return Position(3, 3) + + def get_hp(self, entity_id=None): + return 30 if self.damaged else 40 + + def get_max_hp(self, entity_id=None): + return 40 + + def can_heal(self, pos): + return True + + def heal(self, pos): + self.healed = pos + + +def test_damaged_covering_gunner_is_healed_before_core(): + player = BOT.Player() + player._friendly_covering_gunner = lambda ct, threat: 7 + player._heal_or_approach_core = lambda ct, pos: (_ for _ in ()).throw( + AssertionError("core heal should not preempt countergun maintenance") + ) + ct = TendController(damaged=True) + player._run_home_defense(ct, Position(3, 4), Position(3, 1)) + assert ct.healed == Position(3, 3) + + +def test_healthy_covering_gunner_keeps_existing_core_behavior(): + player = BOT.Player() + player._friendly_covering_gunner = lambda ct, threat: 7 + called = [] + player._heal_or_approach_core = lambda ct, pos: called.append(pos) + ct = TendController(damaged=False) + player._run_home_defense(ct, Position(3, 4), Position(3, 1)) + assert ct.healed is None + assert called == [Position(3, 4)] + + +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 set(candidate) == set(control) | {"_friendly_covering_gunner"} + for name in control: + if name not in {"_friendly_gunner_covers", "_run_home_defense"}: + 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_covering_gunner_is_healed_before_core() + test_healthy_covering_gunner_keeps_existing_core_behavior() + test_exact_v9_scope() + test_no_fingerprints() diff --git a/tests/test_meta_counter_tend6.py b/tests/test_meta_counter_tend6.py new file mode 100644 index 0000000..03747f6 --- /dev/null +++ b/tests/test_meta_counter_tend6.py @@ -0,0 +1,65 @@ +"""Tests for the bounded six-heal countergun maintenance budget.""" + +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_meta_counter_tend6" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("meta_counter_tend6", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +class TendController: + def __init__(self): + self.heals = 0 + + def is_in_vision(self, pos): + return False + + def get_position(self, entity_id=None): + return Position(3, 3) + + def get_hp(self, entity_id=None): + return 20 + + def get_max_hp(self, entity_id=None): + return 40 + + def can_heal(self, pos): + return True + + def heal(self, pos): + self.heals += 1 + + +def test_maintenance_stops_after_six_lifetime_heals(): + player = BOT.Player() + player._friendly_covering_gunner = lambda ct, threat: 7 + core_fallbacks = [] + player._heal_or_approach_core = lambda ct, pos: core_fallbacks.append(pos) + ct = TendController() + for _ in range(BOT.COUNTER_TEND_HEAL_LIMIT + 2): + player._run_home_defense(ct, Position(3, 4), Position(3, 1)) + assert ct.heals == BOT.COUNTER_TEND_HEAL_LIMIT == 6 + assert len(core_fallbacks) == 2 + + +def test_inherited_scope_and_no_fingerprints(): + 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() + source = (BOT_DIR / "main.py").read_text(encoding="utf-8").lower() + assert "self._countertendheals += 1" in source + for fingerprint in ("sweden.map", "string.map", "bridge.map", "ijti"): + assert fingerprint not in source + + +if __name__ == "__main__": + test_maintenance_stops_after_six_lifetime_heals() + test_inherited_scope_and_no_fingerprints() diff --git a/tests/test_meta_counter_tend8.py b/tests/test_meta_counter_tend8.py new file mode 100644 index 0000000..099519e --- /dev/null +++ b/tests/test_meta_counter_tend8.py @@ -0,0 +1,53 @@ +"""Tests for the duel-length eight-heal countergun maintenance budget.""" + +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_meta_counter_tend8" +PARENT = ROOT / "bots" / "meta-generalist-v1" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("meta_counter_tend8", BOT_DIR / "main.py") +BOT = module_from_spec(spec) +spec.loader.exec_module(BOT) + + +class TendController: + def __init__(self): + self.heals = 0 + + def is_in_vision(self, pos): return False + def get_position(self, entity_id=None): return Position(3, 3) + def get_hp(self, entity_id=None): return 20 + def get_max_hp(self, entity_id=None): return 40 + def can_heal(self, pos): return True + def heal(self, pos): self.heals += 1 + + +def test_maintenance_covers_one_symmetric_gunner_duel(): + player = BOT.Player() + player._friendly_covering_gunner = lambda ct, threat: 7 + fallbacks = [] + player._heal_or_approach_core = lambda ct, pos: fallbacks.append(pos) + ct = TendController() + for _ in range(BOT.COUNTER_TEND_HEAL_LIMIT + 2): + player._run_home_defense(ct, Position(3, 4), Position(3, 1)) + assert ct.heals == BOT.COUNTER_TEND_HEAL_LIMIT == 8 + assert len(fallbacks) == 2 + + +def test_scope_and_no_fingerprints(): + for name in ("initialSpawning.py", "mapPathfinding.py", "symmetry.py"): + assert (BOT_DIR / name).read_bytes() == (PARENT / name).read_bytes() + 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_maintenance_covers_one_symmetric_gunner_duel() + test_scope_and_no_fingerprints() diff --git a/tests/test_meta_damage_sequential.py b/tests/test_meta_damage_sequential.py new file mode 100644 index 0000000..3757cd4 --- /dev/null +++ b/tests/test_meta_damage_sequential.py @@ -0,0 +1,79 @@ +"""Tests for damage-gated sequential home countering.""" + +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_sequential" +PARENT = ROOT / "bots" / "meta-generalist-v1" +sys.path.insert(0, str(BOT_DIR)) +spec = spec_from_file_location("meta_damage_sequential", 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_healthy_core_keeps_first_target(): + threats = [threat(2, 3, 10), threat(4, 3, 11)] + assert BOT.choose_home_threat( + threats, {Position(2, 3)}, False) == Position(2, 3) + + +def test_damaged_core_advances_to_uncovered_target(): + threats = [threat(2, 3, 10), threat(4, 3, 11), threat(6, 3, 12)] + assert BOT.choose_home_threat( + threats, {Position(2, 3)}, True) == Position(4, 3) + assert BOT.choose_home_threat( + threats, {Position(2, 3), Position(4, 3)}, True) == Position(6, 3) + assert BOT.choose_home_threat( + threats, {item[4] for item in threats}, True) == Position(2, 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] + source = (BOT_DIR / "main.py").read_text(encoding="utf-8") + assert ( + "sequential = ct.get_hp() < DAMAGE_SEQUENTIAL_HP and len(threats) >= 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_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_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_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_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_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() 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_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") 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 0000000..936af0f Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/contact-01-04.jpg differ diff --git a/tmp/pdfs/pantheon_postmortem/contact-05-08.jpg b/tmp/pdfs/pantheon_postmortem/contact-05-08.jpg new file mode 100644 index 0000000..cfc7bfd Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/contact-05-08.jpg differ diff --git a/tmp/pdfs/pantheon_postmortem/contact-09-12.jpg b/tmp/pdfs/pantheon_postmortem/contact-09-12.jpg new file mode 100644 index 0000000..a6b11d7 Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/contact-09-12.jpg differ diff --git a/tmp/pdfs/pantheon_postmortem/contact-13-16.jpg b/tmp/pdfs/pantheon_postmortem/contact-13-16.jpg new file mode 100644 index 0000000..a9f7f02 Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/contact-13-16.jpg differ diff --git a/tmp/pdfs/pantheon_postmortem/contact-17-20.jpg b/tmp/pdfs/pantheon_postmortem/contact-17-20.jpg new file mode 100644 index 0000000..e9cf12c Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/contact-17-20.jpg differ diff --git a/tmp/pdfs/pantheon_postmortem/contact-21-24.jpg b/tmp/pdfs/pantheon_postmortem/contact-21-24.jpg new file mode 100644 index 0000000..9ec30b4 Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/contact-21-24.jpg differ diff --git a/tmp/pdfs/pantheon_postmortem/page-01.png b/tmp/pdfs/pantheon_postmortem/page-01.png new file mode 100644 index 0000000..ea1d69d Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-01.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-02.png b/tmp/pdfs/pantheon_postmortem/page-02.png new file mode 100644 index 0000000..7b17248 Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-02.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-03.png b/tmp/pdfs/pantheon_postmortem/page-03.png new file mode 100644 index 0000000..b48d69d Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-03.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-04.png b/tmp/pdfs/pantheon_postmortem/page-04.png new file mode 100644 index 0000000..4691735 Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-04.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-05.png b/tmp/pdfs/pantheon_postmortem/page-05.png new file mode 100644 index 0000000..e9a6a5d Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-05.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-06.png b/tmp/pdfs/pantheon_postmortem/page-06.png new file mode 100644 index 0000000..cfeb6b7 Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-06.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-07.png b/tmp/pdfs/pantheon_postmortem/page-07.png new file mode 100644 index 0000000..9a7fae3 Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-07.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-08.png b/tmp/pdfs/pantheon_postmortem/page-08.png new file mode 100644 index 0000000..849e5dd Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-08.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-09.png b/tmp/pdfs/pantheon_postmortem/page-09.png new file mode 100644 index 0000000..127ee89 Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-09.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-10.png b/tmp/pdfs/pantheon_postmortem/page-10.png new file mode 100644 index 0000000..6c837bd Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-10.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-11.png b/tmp/pdfs/pantheon_postmortem/page-11.png new file mode 100644 index 0000000..5eb2184 Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-11.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-12.png b/tmp/pdfs/pantheon_postmortem/page-12.png new file mode 100644 index 0000000..f8d606c Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-12.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-13.png b/tmp/pdfs/pantheon_postmortem/page-13.png new file mode 100644 index 0000000..e2b0b13 Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-13.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-14.png b/tmp/pdfs/pantheon_postmortem/page-14.png new file mode 100644 index 0000000..bba70fe Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-14.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-15.png b/tmp/pdfs/pantheon_postmortem/page-15.png new file mode 100644 index 0000000..604bd9c Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-15.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-16.png b/tmp/pdfs/pantheon_postmortem/page-16.png new file mode 100644 index 0000000..d2cc42c Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-16.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-17.png b/tmp/pdfs/pantheon_postmortem/page-17.png new file mode 100644 index 0000000..caa0452 Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-17.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-18.png b/tmp/pdfs/pantheon_postmortem/page-18.png new file mode 100644 index 0000000..8a0479e Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-18.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-19.png b/tmp/pdfs/pantheon_postmortem/page-19.png new file mode 100644 index 0000000..7117bc7 Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-19.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-20.png b/tmp/pdfs/pantheon_postmortem/page-20.png new file mode 100644 index 0000000..720567a Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-20.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-21.png b/tmp/pdfs/pantheon_postmortem/page-21.png new file mode 100644 index 0000000..302c06b Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-21.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-22.png b/tmp/pdfs/pantheon_postmortem/page-22.png new file mode 100644 index 0000000..6c4db84 Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-22.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-23.png b/tmp/pdfs/pantheon_postmortem/page-23.png new file mode 100644 index 0000000..fa16eb8 Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-23.png differ diff --git a/tmp/pdfs/pantheon_postmortem/page-24.png b/tmp/pdfs/pantheon_postmortem/page-24.png new file mode 100644 index 0000000..c6676ff Binary files /dev/null and b/tmp/pdfs/pantheon_postmortem/page-24.png differ diff --git a/tmp/pdfs/pantheon_postmortem/postmortem-flow.txt b/tmp/pdfs/pantheon_postmortem/postmortem-flow.txt new file mode 100644 index 0000000..286ea23 --- /dev/null +++ b/tmp/pdfs/pantheon_postmortem/postmortem-flow.txt @@ -0,0 +1,1418 @@ +===== PAGE 1 ===== +Cambridge Battlecode Postmortem +Team Pantheon +Derek Wang Yufan Wang +UniversityofOxford GeorgiaInstituteofTechnology +Alex Thummalapalli Ioannis Pantidis +GeorgiaInstituteofTechnology DelftUniversityofTechnology +Overview Eachmatchisplayedonarectangularmap,ranginginsize +WecompetedintheinauguralCambridgeBattlecodeasTeam +from20×20to50×50andguaranteedtobehorizontally, +Pantheon, consisting of Derek Wang, Yufan Wang, Alex vertically,orrotationallysymmetric.Eachteamstartswith +Thummalapalli,andIoannisPantidis. asinglecoreunitthatcanspawnbuilderbots,eachrunning +anindependentcopyofthesameprogram. +Weenteredthecompetitionwithmixedlevelsofexperience. +For three of us, this was our first year doing Battlecode. Thoughthemapisinitiallycomposedoffourbasictiletypes +Ultimately,weplacedfirstintheGrandFinalswithourbot, (empty,titaniumore,axioniteore,walls),builderbotscan +Khaos.Thefinalbot,alongwithourtooling,canbefoundin constructnewinfrastructurethroughoutthegamesuchas +ourGitHubrepository. conveyors,turrets,androads. +Figure2:Mapfeatures:titaniumoreinblue,axionite +oreinorange,andwallsinblack. +Figure 1: A Grand Finals match between Pantheon +(gold)andsomethingelse(silver). +Resources +GameOverview Titaniumistheprimaryresourceandisusedtobuildbuild- +ings,heal,andasammunitionformostturrets.Rawaxionite +CambridgeBattlecodeisasix-weekcompetitioninwhich +iswhatisminedfromaxioniteore.Rawaxioniteandtitanium +teamsdesignanddevelopalgorithmstoautonomouslyplay +canbecombinedtocreaterefinedaxionite(typicallyreferred +aturn-basedstrategygame.Thisyear’sgameinvolvedbal- +toassimply“axionite”),whichisapremiumresourcewith +ancingresourcemanagement,mapcontrol,andcombatby +morelimiteduses,althoughitcanbeconvertedbackinto +dynamicallyroutingconveyorlinesacrossa2Dmap. +titaniumorusedfortiebreaks. +ThoughitwasinspiredbyMITBattlecode,thiscompetition +featuredsomekeydifferences;notably,aswitchfromJava +toPython,newtimelimitconstraintsinplaceofbytecode +counting,andalargejumpincomplexity. + +===== PAGE 2 ===== +Wangetal. +Core BuilderBot Conveyor ArmouredConveyor Bridge Splitter Harvester +Foundry Gunner Sentinel Breach Launcher Road Barrier +Figure3:Gameentitiesandbuildings. +Core EconomicBuildings +Eachteamstartswithacore,placedsymmetricallyonoppo- +• Harvesterscan be builton titaniumor axioniteore to +sitesidesofthemap.Thecoreisanimmobile3×3building. +harvestresources.Theyoutputresourceseveryfourturns +toacardinallyadjacentbuilding,likeaconveyororturret. +Itcanspawnbuilderbotunitsononeofitstilesonceper +turn,anditiswalkableonlybyalliedbuilderbots. • Foundriesconverttitaniumandrawaxioniteintorefined +axionite.Theyacceptinputfromanysideandwillout- +Resourcesmustbedeliveredtothecorebeforetheybecome +putrefinedaxioniteafterconsumingbothtypesofinput +globallyaccessibletoallunits.Thecorealsohastheability +resources. +toconvertaxionitetotitaniumina1:4ratio. +Turrets +BuilderBots +Turretsaretheprimarywayofattacking.Allturrettypes +Theprimaryunitinthegameisthebuilderbot.Itistheonly +exceptlaunchersrequireammotofire,meaningresources +unitthatcanmoveorconstructbuildings. +mustbefedintothemfromanon-facingside.Therearefour +Builderbotscanmoveonceperturntoanadjacenttilewitha typesofturrets: +walkablebuildingonit.Theycanalsoperformanactiononce +• Gunnersarehigh-DPS,low-rangeturretsthatcanonly +perturntohealanadjacententity,constructabuildingon +shootthefirsttargetinrange.Theycanturnbyspending +anadjacenttile,orattackabuildingonthetiletheyoccupy. +titanium. +Builderbotscanalsofreelydestroyadjacentallybuildings +withoutusinguptheiraction. • Sentinels are lower-DPS turrets with a long and wide +range. Unlike gunners, they can shoot any tile within +BuilderbotvisionextendstosquaredEuclideandistance20. +theirrange. +Conveyors • Breachesareveryhigh-DPSturretsthatdealsplashdam- +age.However,theycanonlyuserefinedaxioniteasammo, +• Conveyors send resources to the tile in front of them. +sotheyareseldomused. +Theyacceptresourcesfromthethreecardinalnon-facing +sides. • Launchersareutilityturretsthatcanpickupandlaunch +adjacentbuilderbotsoneitherteamtoanywalkabletile +• Armouredconveyorsactlikeregularconveyorsbuthave +intheirvision. +morehealthandcannotbedamagedbybuilderbots.They +costbothtitaniumandaxionitetobuild. MiscellaneousBuildings +• Bridges are more expensive than conveyors but have • Roadsarecheap,walkablebuildings.Sincebuilderbots +moreoutputoptions:anytilewithinsquareddistance≤9. +cannotwalkonemptytiles,aroadmustbeplacedbefore +Theyacceptresourcesfromanyside. thattileispassable. +• Splittersonlyacceptresourcesfrombehind,buttheysend • Barriers are cheap, high-health buildings that block +outputsequallytothethreeremainingcardinalsides. buildermovement. + +===== PAGE 3 ===== +CambridgeBattlecodePostmortem +WinningtheGame +Therearetwowinconditions:eitherdestroyingtheenemy +coreorwinningbytiebreakafter2000roundshavepassed. +Tiebreakersaredeterminedinorder: +• Axionitecollected +• Titaniumcollected +• Harvestersalive +• Axionitestored +• Titaniumstored +BotSummary +We went through several substantial iterations and even +Figure5:Artemis(Gold)vsStarter(Silver) +entirerewritesoverthecourseofthecompetition. +Starter +Artemis also used a defensive launcher network both to +protectourconveyorsandharvestersandtohelpallybots +quicklymoveacrossthemap. +Demeter +Figure4:Starter(Gold)vsStarter(Silver) +WestartedwithabasiceconomybotwithA*pathfinding, +conveyorrouting,andminimalattacking. +Artemis Figure6:Demeter(Gold)vsArtemis(Silver) +Our initial baseline evolved into a launcher-centric bot +namedArtemis. +AftermergingwithTeamFood,webug-fixedAlex’sbotand +Adedicatedbuilderbotwouldrushtheenemycoreusing portedoverourmapinfo,pathfinding,andgunnerlogicfrom +launcherstolaunchitselfforwardinhopesofdisruptingtheir Artemis. This defensive bot solely focused on developing +earlyeconomyveryquickly.Thenitwouldfindaconveyor infrastructure,butalsohaddynamicturretplacementlogic +toattempttoattackandreplacewithaturrettodestroythe thatwouldrepurposeourownconveyorlinesiftheywere +enemycore.Itwouldplaceadditionallauncherstoflingaway neededmorefordefense.However,wesoonhitaplateau +anyenemybuilderbotattemptingtohealthetiletherusher duetoovercomplicatedcodeandstrategiesandhadtobegin +wasattacking. arewrite. + +===== PAGE 4 ===== +Wangetal. +newsystemusedfactorslikeclosestbuildertoatargettile +todecidewhethertopursueatask. +Khaos +Figure7:Lethe(Gold)vsDemeter(Silver) +Lethe +Keepingonlyourmapinfoandpathfindingmodules,wefully +rewroteourbotwithamemorylessarchitecture.Theidea Figure9:Khaos(Gold)vsHades(Silver) +behindthememorylesssystemwastohaveourbotrecom- +puteinformationeveryturnbasedonglobalmapknowledge, Adaybeforethefinalsubmissiondeadline,wemergedina +ratherthanretainingstalestrategiesorstates.Thisallowed brand new chokepoint detection system and attack route +ourbottobehighlydynamicandmakemuchmoreoptimal strategy that greatly improved our win rate. We named +decisionseachturn,andwefinallystartedwinninggames thisbotKhaosduetoitsunpredictableanddynamicnature, +againsttopteamslikeBlueDragon. whichmadeitdifficultforopponentstocounter. +Hades StrategicOverview +TherestofthispostmortemdocumentshowKhaosworks. +We’llstartwithgeneralstrategies,thencovertechnicalim- +plementationfromthemapinfolayerthroughpathfinding, +states,turretlogic,andruntimeoptimization. +MemorylessArchitecture +Ourbitmaskedmapinfolayerisfastenoughforustorecom- +putekeydecision-makingandplanninglogicfromscratch +eachturn.Ratherthandefiningstatetransitionsbasedon +previousstates,wescoresevencandidatestateseachturn +basedoncurrentmapknowledgeandexecutethehighest +scoringstate. +Theintentisforlong-termbehaviortoemergefromevery +builderactingoptimallyonitscurrentlocalknowledge.This +makeseachbot’sactionsmuchmoredynamic,sincethey +Figure8:Hades(Gold)vsLethe(Silver) don’tpersiststalegoalsandcaneasilyadapttotheactions +ofotherbots,newlydiscoveredinformation,etc. +AftertheUSQualifiers,weaddedmanysmallimprovements +AdversarialStrategies +andpartialrewritestoLethe.Wealsorevampedourcoordi- +nationsystem,whichpreviouslyusedmarkersforbotcoor- Althoughtheseterritorialstrategiesarenothard-coded,they +dination,sinceitwasbeingdisruptedbyotherteamsplacing emergefromourstatemanagementandscoringsystemand +roadseverywhereandblockingmarkerplacementspots.The areworthhighlighting. + +===== PAGE 5 ===== +CambridgeBattlecodePostmortem +Ore hopping (Figure 10) is our strategy of using ally- +controlled harvesters as staging points for attack turrets. +By placing turrets along the frontier of our controlled +territory,wecanshootthedefensesaroundnearbyopponent +harvesters.Ourbuilderbotscanthentakeovertheenemy +harvesterbybuildingmoreturretsaroundit. +Orehoppingisaconsistentwayofpushingourfrontline +Before After +forward,sinceslowlytakingoverenemyharvestersbolsters +our economy and increases map control. This often will +Figure11:Seeding. +createturretcombatalongthefrontier. +Theonlydownsideoforehoppingisthatitishighlydepen- Wefrequentlyusegeometrictranslationsofasetoftilesby +dentonorespreadacrossthemap.Iforeclumpsaren’tspread usingtheintegershifts≪1,≫1,≪𝑊,≫𝑊 toshiftatileset +outenough,aturretplacedatanallyorecannotreachan east,west,south,northrespectively.Thesegeometrictrans- +enemyore,anditishardertohopandmeaningfullyexpand lationsareusefulforshiftingbitmaskedfrontiers,computing +ourterritory. neighborhoods/zones,etc. +Overall,map_infomaintainsaroundthirtyderivedmasksto +storeinformationabout: +• Environment/Entitytype +• Facingdirection +• Current/previousvisibility +• Resourceflow +• Dangerzones +• Placementlegality +Before After Wecancombinethesemaskstoqueryvariousrelevantsub- +setsoftheboard.Forexample,anavoidmaskcanbequickly +Figure10:Orehopping. computedbyORingwalls,enemyturretthreatmasks,and +somebuildings. +Seeding (Figure 11) is a more aggressive way of gaining +ValuesthatcannotbeasinglebitarestoredinPythonlists +territory.Thisinvolvesbuilderbotsinterceptinganenemy +indexedby𝑛:buildingid,entitytype,hitpoints,facing,con- +conveyorlinebyattackingituntilanallied“seed”turretcan +veyoroutputtile,andareverse-adjacencybitmaskofevery +beplacedinthegap.Theanalogyisthatmuchlikeseeding +conveyor-typebuildingthatoutputsonto𝑛.Thatreversead- +a plant, our infrastructure can grow from a single turret +jacencyoftheconveyorgraphallowsforveryfastconveyor +investmentasitdestroysenemytargetsarounditandfrees +flowrelatedcalculations. +upspaceformoreofourturretsandconveyorstobeplaced. +Seedingiseasilycounteredbyenemybuilderbotssimply UpdatingVision +out-healing the attack if nearby, though there are special update_at(pos) calls the cambc controller to update our +casesdescribedlaterwherethiscanbeprevented. knowledgeofaspecifictile.Itiscalledforeverytileinourvi- +sion.Wealsocallitonrelevanttilesafterwebuildsomething +MapInfo +orwhennewtilesenterourvisionaftermoving. +Themapisatmost𝑊×𝐻 ≤50×50tiles,sowecanfitbinary +Sincethismethodiscalledsofrequently,weonlyupdaterel- +information about tiles within a Python big integer of at +evantinfotoavoidwastingruntimeonunneededcontroller +most2500bits.Weencodeinformationabouttile (𝑥,𝑦) in +calls: +bit𝑛 =𝑥 +𝑦𝑊.Byusingbigintegersforvariousproperties +(whetheratileisawall,ifwe’vevisitedatile,etc.),wecan (1) Core-area tiles are handled by separate core logic; +quicklyqueryandcomposespatialinformationaboutthe onlycorehealthisrefreshedeachturnandthefunc- +mapwithC-levelbitwiseoperations. tionreturnsearly. + +===== PAGE 6 ===== +Wangetal. +(2) Symmetry tracking runs once per newly-seen tile. Forsomemasks,wealsoaddourobservedresourcebitmasks +Observingatilethatcontradictsasymmetricalcoun- to the cache key so that the masks are recomputed as re- +terpartclearsthatsymmetryflag. sourcesflow: +cache_key = (_struct_version, +(3) Wallscanneverholdabuildingandneverchange, +_bm_conv_ti, +soiftheobservedtileisawall,thefunctionreturns +_bm_conv_raw_ax, +beforethebuildinglookup. _bm_conv_refined) +(4) Otherwise, we check the building id. If it is un- +DerivedMasks +changedfromlastturn,weonlyrefreshhealthand +stored resources, as well as facing direction for Weuseourbasicmapinfomaskstoderivemorecomplex +gunners(theoneunitthatturns). masksfordecisionmaking. +Enemyturretthreat:Wecomputebothasoftmask(enemy +(5) If the building id changed or was cleared, we up- +sentinelattacktiles)andahardmask(enemygunner/breach +dateallofourbitmaskswiththenewentityinfoand +attacktiles)toguidebuilderdecisions. +bumpthestructuralversioncountersoanycached +calculationsthatrelyonmapinfoaremarkedstale. Wepurposelyneverstepwithinhardmasktilesduetothe +Formarkers,weaddtheirdecodedmessageintothe higherDPSofgunnersandbreaches.Weallowsteppinginto +messagequeue. softmasktilesinourpathfinding,justatahighercost. +Toconstructthesemasks,weprecomputetheattackpatterns +PredictingSymmetry +ofsentinelsandbreachesforeachfacingdirection,thenapply +Sincemapsareguaranteedtobeeitherhorizontally,verti- thesepatternsforturretsfacingeachdirection: +cally,orrotationallysymmetric,welookfortilesthatcontra- +dicteachpossiblesymmetryuntilthereisonlyoneremain- for d in 8 directions: +turrets_d = enemy_turrets & dir_mask[d] +ingcandidate.Wethenusethedeterminedsymmetrytoset +for (dx,dy) in OFFSETS[turret_type][d]: +theenemycorelocationandupdateourknowledgeabout acc |= shift(turrets_d & col_mask, dx + dy*W) +unseentiles: +Sincegunnerscanonlyshootthefirsttileinfrontofthem, +if (hor + ver + rot) == 1 and not solved_sym: +solved_sym = True wechecktheirfacingdirectiononestepatatime,ignoring +# update enemy core location anytilespastablockingwall. +their_core = flip(my_core) +# mirror seen tile envs to unseen half +Thehardandsoftmasksarememoizedon_struct_version +for n in seen_tiles: tominimizerecomputation. +f = flipped_index(n) +if not (seen & bit(f)): Turretfeedpropagation:Weconstructabitmasktomark +copy_env(n → f); seen |= bit(f) whichtilesareproperlyfed.Wepropagatewhetheratileis +struct_version += 1 fedfromharvesters,foundries,andconveyorswithvisible +resourcesonthemupto4stepsdownstream(basedonoutput +Iftherearetwoormorepossiblesymmetries,westillcom- tiles).Thisbitmaskisusedforturretplacementlogictoonly +mittoapredictiononwheretheenemycoreis.Wedefault considertileswithasourceoftitaniumorrefinedaxionite +toassumingrotationalsymmetryunlessit’salreadybeen feedingthem. +eliminated,inwhichcasewepredictthecloserofthehori- +Routetargetsanddeadends:Onepasswalkstheconveyor +zontallyorverticallysymmetricalcorelocations,withthe +graphbackwardfromthecoreareaviathereverseadjacency, +tiebreakrememberedsothepredictiondoesn’toscillatebe- +inlayers,recordingboththereachedsetandatopological-ish +tweenturns.Thispredictedlocationisusedtopredictally +visitorder.Asecondpassthenoverlaysloadedandvisible +vsenemyterritoryandforattackrouting. +state: +Memoization +We memoize complex bitmasks based on a struct version +counter that is incremented on structural changes to the +knownmap(walldiscoveredorbuildingdiscovered/placed +/destroyed). + +===== PAGE 7 ===== +CambridgeBattlecodePostmortem +Communication +reaches_core, order = route_reaches_core() +# walk in core-ward order Sincerobotsaresandboxed,allcommunicationmustbere- +for n in order: layedviamarkers.Aunitmayplaceatmostonemarkerper +rl = (run_len[target(n)] + 1 turncarryingau32value. +if loaded(n) else 0) +run_len[n] = rl +PayloadFormat +if rl == 4: # 4 in a row: line jammed +mark 4 tiles from n unroutable Earlier iterations also used markers to communicate task +# a *visible* 4-run jams the whole chain: claimsbetweenbuildersandtouselauncherstospeedup +# flood both up- and downstream through +builderbotmovement,butthosewereretiredinHades.The +# my conveyors +symmetry-broadcastpayloadrepurposedthelowerbitsas +route_targets = core_area | (reaches_core +& ~unroutable a 7-bit corresponding-position code, a 21-bit environment +& ~guard_conveyor) sample,and3symmetrybits. +All field widths and shifts are module constants and the +Ifweobserve4loadedconveyorsinarow,weconsiderthat encode/decodepairisastraightbit-pack: +conveyorlineunroutable,andignoreitinourDijkstra’sfor +conveyorrouteplanning.Thisapproximatesmaxflowsince def encode(target, type, sym, sender): +return ((target & POS_MASK) +wedon’twanttoconnectmoreharvesterstoaroutethatis +| (sym << SYM_SHIFT) +alreadyfull. | (sender << SENDER_SHIFT) +| (type << TYPE_SHIFT)) ^ key +Asasideeffect,thesamepasssetsadead-endmask(targetsof +loadedconveyorswhoseoutputisnotanacceptingbuilding, ThefinalXORwithkeyencryptsourmarkers.Weinitially +plusconveyorspointingintoenemyhardbuildings)anda addedthisasajoke.However,wekeptitintomakeitharder +feeding-enemymask(loadedconveyorswhosetargetisan forteamstodecodemarkersinreplaystofigureouthowour +enemyturret).Theroutestateattemptstoroutedead-ends commssystemworks. +andfeeding-enemyconveyorstoroutabletargets. +SymmetryBroadcasts +We also have a visible-graph check for whether a turret +Althoughtheprimarypurposeofthismarkertypeistoshare +couldpossiblystillbefed.Thischeckisusedbyturretsto +mapsymmetry,wealsouseittosharemapknowledgeabout +decide self-destruction validity: any unseen adjacent tile +distantpartsofthemap. +oroff-screenupstreamfeederkeepstheanswerTrue,soa +turretisonlyallowedtoself-destructwhenthevisiblelocal Wefirstbucketthemapinto5×5cells.Sincemapdimen- +structureprovesitisnotcurrentlysupplied. sionsareatmost50×50,thereareupto100buckets,and +sowecancommunicatewhichbucketwearesharinginfo +Carrying resources: We also maintain a bitmask that +aboutwith7bits.Wethenuse21bitstocommunicatebinary +expands three steps up and downstream from a resource- +informationabout21tileswithinthebucket(alltileswithin +carrying conveyor. We maintain separate bitmasks for squared radius ≤ 5 from the center of the bucket). If the +eachresourcetypesowecancheckifatilecouldcarrya markerisplacedonaneven𝑥 coordinate,wecommunicate +particularresource. +whether or not the tile environment is a wall. Otherwise, +wecommunicatewhetherornotitisatitaniumore.This +Avoidpathing:Wealsomaintainamaskoftilesweshould +resultsineachtypebeingcommunicatedbyroughlyhalfof +avoidwhenpathfinding.Ouruniversalavoidmaskincludes +ourmarkers. +walls,non-walkablebuildings,tilesaroundenemylaunchers, +and tilescovered byenemy turrets. We canalso compute Anyallybotthatreadsthemarkerthenusesthelearnedinfo +avoidmasksthatincludeallyconveyors(sowedon’tchange toupdatethecorresponding_bm_enventryand_bm_seen: +existinginfrastructurewhenplanninganewconveyorroute), +def apply_symmetry_broadcast_map(marker_pos, bucket, bits): +enemyroadsadjacenttoenemybots(sincetheyareeasily +env = ORE_TI if marker_pos.x & 1 else WALL +healable),chokepoint barriers,andnon-landlockedorein for i in set_bits(bits): +theharvestzone. dx,dy = LEARN_MAP_OFFSETS[i] +note_env(bucket.x+dx, bucket.y+dy, env) +WealsohaveanenemyPOVmodetocalculateavoidmasks +with inverted perspective (shows how the enemy would Wallsarethemorevaluableofthetwopayloadtypes,since +perceivetilestoavoid). knowledge of impassable tiles helps our pathfinding and + +===== PAGE 8 ===== +Wangetal. +chokepointdetectionlogic.Titaniumoreobservationsdon’t +# seed the four cost classes into the bucket +affectthesesinceoreispassable,butitdoeshelpformore +frontier[0] = target & no_barr & no_thr +efficientharvestingsinceweknowwhereharvestersshould frontier[bcost] |= target & barr & no_thr +beplacedbeforeexploringthemap. frontier[tcost] |= target & no_barr & thr +frontier[bcost+tcost]|= target & barr & thr +Overall,ourcommunicationsystemissupplementaryonly. i = 0 +Botsdon’tdependoncommunicatedinfo,buttheycanben- while True: +efitfromusingmarkerstolearnmoreofthemap. cur = frontier[i % cyc] & ~visited +visited |= cur +Pathfinding # reached a valid neighbor +if cur & can_move_to: break +WerunabitmaskedversionofDial’salgorithmtofindthe nbr = expand_chebyshev(cur) & ~visited & ~avoid +optimalpathtoagiventarget.Bychangingtheallowedsteps frontier[(i+1) % cyc] |= nbr & nb_nt +frontier[(i+1+bc) % cyc] |= nbr & b_nt +andcost,wecanusethisbothforbuilderbotmovementand +... (3 more cost classes) +conveyorrouteplanning. +i += 1 +Thesearchisruninreverse,fromtargettilestostart,sothat +pathreconstructionisnotnecessary,asitcansimplyfind When a frontier bit lands on a valid neighbor of the bot +thelowestdistance-to-targettileitcanmoveonto. (thatis,aneighborwecanstepontorightnow),thatdirec- +tionischosen,afterafour-stagetiebreakamongreachable +Dijkstra’sforMovement +neighbors: +Formovement,wedefinefourdifferentedgetypes:anormal +(1) We prefer walkable tiles so we don’t waste our +step(cost1),astepdestroyinganallybarrier(cost15),astep +actioncooldownonbuildingaroad. +intoasoftthreat(cost20),andboth(cost35). +(2) Weskiptilesnexttobordersandfriendlybots +Weconsideranywallsornon-walkablebuildingstobeim- +uptoChebyshev4ifpossible.Avoidingedgesisgood +passable.Wedonotmarkbuilderbotsasimpassabletopre- +formaximizingvision.Avoidingbuilderbotshelps +ventoscillationsinourroutepathasbotsmovearound.In- +preventclumpingorsituationswhereweaccidentally +stead,weonlycheckoccupancyattheendofourDijkstra’s, +stepontoanotherbuilderbot’stargettile. +whenconsideringthemovewewouldmakethisturn. +Wethenuseacirculararrayoffrontierbitmasksasabucket (3) Wepreferwalkinginzigzags.Ifwewalkalongthe +queue,startingfromamaskofourtargettilesanditerating samediagonalfortwosteps,weprefertheopposite +untilwereachatilewecouldmoveontothisturn: diagonalnext.Theintuitionisthatzigzagginggives +usadditionalvisioncoverage. +(4) Wepreferdiagonalsovercardinals,againtomax- +imizevisioncoverage. +Wealsobreakallybarriersandplaceroadsasneededwhen +movingontoatile.Wewilltrytoreplaceanybarrierswe +brokeatthestartofeachturn. +Dijkstra’sforRouting +Forrouting,wedefinetwodifferentedgetypes:aconveyor +step(cost1)andabridgestep(cost6). +Whenbuildingourtargettilemask,weallowforendingon +Figure12:Dijkstrafrontierexpansionoverfiveitera- existingconveyors,ratherthanthecoreitself.However,we +tions. add a cost of 4 to loaded conveyor tiles, to bias our route +plannertoformnewconveyorlinesunlesstheexistingline +issignificantlybetter. +SincewecannotstoreparentpointerswhenrunningDijk- +stra’s,westillneedtocalculatethedesiredoutputofthefirst +conveyororbridgeweshouldbuild.Wedothisbytesting + +===== PAGE 9 ===== +CambridgeBattlecodePostmortem +eachpossibleoutputtoseeifitexistsinthematchingprior +my_front, other_front = my, others +costlayertoourcurrenttile. +while remaining_claims and (my_front or other_front): +my_front = expand(my_front) & passable & ~claimed +Weusedifferenttargettilesetswhenattackroutingtothe +claimed |= my_front; remaining &= ~my_front +ringaroundtheenemycoreorroutingtoafoundry. +other_front = expand(other_front) & passable & ~claimed +claimed |= other_front; remaining &= ~other_front +ConveyorCost return my_claimed & candidates +Wecalculatethetitaniumcostoftheoptimalconveyorroute +todecidewhetherweshouldstartbuildingitnow.Thisisto StuckDetection +preventbuildersfromstartingroutesfromveryfarawayif +Ifmorethan2+(idmod8) turnspasswithoutmovement +wearelowontitanium. +progress,builderbotswillmovetoarandomadjacenttileto +getunstuck.The(idmod8)termaddssomevariancebased +Wecalculatethecostusingthisformula: +onbuilderbotidsothatthisbehaviordoesn’tinadvertently +syncupacrossbuilderbots. +cost(𝑑) = (cid:0) 3𝑑𝑠+0.015𝑑(𝑑−1) (cid:1) ·0.65, +TheCore +forpathlength𝑑atcurrentscale𝑠.Thisisanarithmeticseries Thecoreismainlyresponsibleforspawningbuilderbots.It +formulathatletsuscalculatethebase3𝑑𝑠costofbuildingthe willalsoconvertaxionitetotitaniumasneeded. +conveyors,withanadditional0.015𝑑(𝑑−1)termtoaccount +SpawningBots +fortheincreasedcostofeachconveyorasscalingincreases. +Thespawnpolicyhasmultiplechecks: +The0.65constantisanarbitrarydiscountweapplytothe +total cost. We don’t want builders to be too conservative if not has_ally and closest_enemy: +aboutstartingroutesbacktothecore,sincewegaintitanium spawn_on_core_tile_nearest(closest_enemy) +else: +overtime(frompassiveincomeeveryfourturns,orother +baseline = 400 if allies ≥ 12 else 200 +existingroutes). +if titanium > baseline + scaling: +spawn_toward_plan() or spawn_toward_center() +BFSforClosestTarget +We used Dijkstra’s for navigation and routing since we Thefirstfourbuildersarespawnedindifferentdirectionsfor +wantedtoaccountformultipleedgeweights.However,we betterexplorationcoverageatthestartofthegame.After +useamuchfasterunweightedBFSwhenwewanttofind that,botsarespawnedtowardavisibleenemyifthereisno +theclosesttargetforactionplanning. allynearbyasadefensivemechanism.Otherwise,wewait +untilwehaveenoughtitaniumandthenspawnalliestoward +This frontier BFS mimics builder bot movement by only thecenter. +expandingpassabletiles,minustileswewanttoavoid(such +as tiles adjacent to enemy launchers). This is used when InitialSpawnPlan +findingtheclosestallybuildingwecouldheal,theclosest +Thecoreorchestratesinitialexplorationbyspawningthe +enemywecouldchase,etc. +firstfourbuildersonthetilefacingthedirectiontheyshould +The closest function also exposes a side flag that lets us explore. +optionallyruntheBFSfromtheenemy’spointofview.This +Tofindthebestdirections,wefirstcheckwhichoftheeight +is used to determine how far the enemy is from a target, +possibledirectionsshouldbeconsidered.Foreachdirection, +whichhelpsusreasonaboutwhetherornotwecouldreach +wechecktheraypointinginthatdirection.Iftheray’send- +thattargetfirst. +point is out of vision, or there is titanium along the ray +path,weconsiderthedirectionvalid,sincethereisvaluein +FloodfillforVoronoiPartition +sendingabuilderbotinthatdirection. +Eachbuilderscoresitsstatesindependently,soanexplicit +Fromthesetofvaliddirections,wepickthefourthatmaxi- +mechanismisneededtostopbuildersfromcompetingover +mizetheproductofpairwiseangularseparations,tospread +tasks.Weuseaclaim-partitionpassthatperformscompeting +thedirectionsout: +simultaneousChebyshevflood-fillsfromthebotversusall +otherfriendlybotsandreturnsonlythecandidatesthebot +reachesfirst: + +===== PAGE 10 ===== +Wangetal. +PersistentBuilderState +best = argmax over 4-subsets S of +prod(angular_dist(a,b) for a,b in pairs(S)) Thereareafewexceptionstoourmemorylessarchitecture. +WemaintainTTLcachesforeachstatesothatbuilderswill +notreconsidertargettilesthatfailedrecently.Wealsochange +We tiebreak by preferring diagonals if there are multiple +thebehaviorofthefirstfewbuilders: +setsofdirectionswiththesamepairwiseangularseparation, +sincediagonalsgiveusbettervisioncoverage. +The very first builder spawned will restrict its targets to +squareddistance≤100ofthecore,soitstaysnearthecore +Oncewehavethefourinitialdirections,wespawnbotsin +asaguard. +orderoftheirdirection’sproximitytothemapcenter,since +thatismorelikelytobeacontestedregionandwewantto Thefirstfourbuilderswillcheckthetiletheyspawnonto +gettherebeforetheenemydoes. decidetheirinitialexploredirection(seeInitialExploration). +Theywillclearthistargetuponarrivaloraftera30-round +ConvertingAxionite +timeout. +Weusethefollowinglogicforaxioniteconversion: +States +if round < 1500 and titanium < 4 * harvester_cost: +convert(clamp((3*harvester_cost - titanium)//4)) Builderbotshavesevenpossiblestatestheycanrun.Every +stateexposesascore()functionthatusesglobalmapinfo +tocalculatestatepriorityandreturnavaluestrictlybelowa +Thisallowsustoconvertaxionitetotitaniumwhenweare +fixedceiling(showninTable1).Wethenrunthelogicfor +runninglow,upuntilturn1500,sinceaccumulatingaxionite +thehighestscoringstate. +forthetiebreakerisimportantinlong-runninggamesthat +mightreachround2000. +Table1:Builderstatesbyscoreceiling. +BuilderBots +State Max Role +Builderbotsdothefollowingeachturn: +attack 9 buildsentinel/gunner +(1) Updatemapinfousingthecambccontrollerand +heal 8 repair/chaseinvaders +anylearnedmapinfofromallymarkers +route 7.75 routeresourcesback +(2) Rebuildbrokenbarriersifwedestroyedanypre- secure 7.5 surroundanoretile +viouslyformovement harvest 4 buildaharvester +disrupt 2 barrierenemy-sideore +(3) Scorestatesandrunthebeststate +explore 1 exploreunseentiles +(4) Placealauncherorbarrierifwedetectanearby +chokepoint We calculate state scores in descending order of the max +possiblescoreforthatstate.Thisavoidshavingtocompute +(5) Healanadjacentbuildingandfallbacktohealing +score()onlower-ceilingstatesthatcannotwinoncewe’ve +ourselvesotherwise +foundaprovablybetterscore: +(6) Destroyenemyfeedersifadjacent +for state in states_desc: # sorted by MAX_SCORE +(7) Tryspammingroadsifemptytileadjacent if best_score ≥ state.MAX_SCORE: +break +(8) Placemarkertobroadcastsymmetryandmapinfo s = state.score() +if s > best_score: +Anenemyfeederisanallyconveyor,bridge,orharvester best_score, best = s, state +thatoutputsontoanenemyturret.Forharvesters,wealso +checkthattherearenoallyturretsbeforedestroyingit. StatePattern +Whenspammingroads,weprioritizeplacingroadsadjacent Eachstatefollowsthesamepattern.Thescore()function +toallyconveyors.Wealsoplaceroadsaroundenemybots derivescandidatetilesforthestate(suchasbuildingswecan +orbuildingsifanenemyisnearby.Roadspamhelpsuswith healforhealstate).Weintersectthecandidatetilesetwitha +mapcontrolsinceenemieshavetodestroytheroadsfirst Voronoiclaimpartitiontoavoidmultiplebuilderscontesting +beforetheyplacetheirownbuildings.Wedonotspamroads thesametargettile.Wethencachetheresultforrun()to +infrontofourgunnerstokeeptheirfiringpathunblocked. useifthestateendsupbeingchosen. + +===== PAGE 11 ===== +CambridgeBattlecodePostmortem +Attack(ceiling9) Gunner scoring follows the same idea along a directional +Attackisourhighestprioritystatewhereweplaceturretsto rayofuptothreesteps.Thescoreisdiscountedby0.9 𝑘 at +targetenemybuildings.Wepreferplacingturretsonempty +step𝑘,therayisblockedbywallsandourownnon-road +tilesorlessimportantinfrastructure(roads,guardconvey- buildings,andthecoreissingle-countedatitsclosesthit.This +ors,etc).Wewillfallbacktoplacingonnon-guardconvey- stepdiscountprioritizesplacinggunnersdirectlyadjacent +ors/bridgesorenemyinfrastructurewecoulddamage,but totargets,sincetheycan’tshootovertileslikesentinelscan. +atahigherthreshold. Wealsoaddarotationbonusequaltothesumoveralleight +facings shifted right by 3, to still favor targets we might +rotatetowardlater. +Weapplyapenaltytoplacementpositionsunderthreatby +enemyturrets.Wealsoapplyapenaltytoplacementsthat +woulddestroyanallynon-guardconveyororbridge.Allof +this is memoized per round keyed by the structural state, +enemypresence,andplacementmasks. +FilteringTiles +The candidate pass returns the legal placement mask per +facingdirection.Weseedfromtilesfedbytitaniumoraxion- +Figure13:Attack. +ite,plustheManhattandilationoftitaniumharvestersand +foundriesnotalreadyusedbyoneofoursentinels.Sentinels +Bit-SlicedScorePlanes consumemoreammothanaharvestercanprovide,sowe +avoidbuildingmultiplearoundthesameharvester. +Foreachfacingandturrettype,wemaintainascoreplane +stack:alistof9bitmaskswherebit𝑖oftheintegerscoreattile +Wethenintersectwithseenandbuildabletiles,andremove: +𝑛is(planes[i] » n) & 1.Thisletsusdobulkscoreupdates +andqueriesoverthewholeboardwithbitwiseparallelism, • Tileswithinatwo-stepenemy-botBFSthroughtheirpass- +ratherthaniteratingoveranarray. ablegraph.Wetreatourlauncher3x3zonesasimpassable +forenemies,sinceanenemyenteringoneisflungback. +Toaddaconstant𝑐 tothescoreofeverytileinamask𝑀, +foreachsetbitof𝑐 weXORthecorrespondingplanewith • Our own turret feed chains, so a new placement never +𝑀 (togglingthatdigitateverytilein𝑀 simultaneously)and cutsoffanexistingturret. +rippleanyAND-carriesupthroughhigherplanes: +• Per-directionloaderblockers,sincefeederscannotloada +The key property is that the cost of calculating scores is turretpointingintothem. +independent of the number of tiles scored. The per-digit +Sentinelsadditionallyexcludetileswithinenemygunneror +loopisboundedbythefixedplanecount,andeachiteration +breachrange,sincetheyshootslowlyandareunlikelyto +isonebig-integerAND/XORoverthewholemap.Thisis +winthetrade.Gunnersarepermittedonthosetilestoenable +whatmakesexhaustiveplacementevaluationfitwithinour +harvestercombat. +per-turnbudget. +Weonlyconsiderplacementtileswhosescorespassathresh- +ScoringTiles +old.Sentinelsandgunnershavedifferentthresholds,since +Wegroupenemybuildingtypesbyequalscoresoonemasked theyhaveslightlydifferentscoringsystems.Thethreshold +addhandlesawholescoreclass.Foreachoftheeightfacings, isalsohigherforenemyinfrastructurewewouldhaveto +thesentinelpassshiftseachenemy-typemaskbythereverse destroyfirstbeforeplacing,orallyinfrastructurewedon’t +ofeverysentinelattackoffsetunderthatfacingandANDs wanttodestroy(non-guardconveyorsandbridges). +withthevalid-placementmask.Whereverabitsurvivesisa +tilefromwhichasentinelfacing𝑑 wouldhitthatenemy,so PlacingTurrets +weaddthetype’sscoreintotheplanesthere. +Ifwecanplaceaturretonapreferredtilethisturn,either +Theadvantageofthisapproachisthatweneveriterateover fromourcurrenttileoranadjacenttile,wepickthehighest +individual placement tiles. The total time scales with the scoringsuchplacement.Thisistofavorturretswecanplace +numberofhitoffsets,facings,buildingtypes,andscorebits, thisroundoverpotentiallybetterplacementswewouldonly +ratherthanwiththeboardsize. reachinalaterround. + +===== PAGE 12 ===== +Wangetal. +# Add the constant c (c = sum of 2^b for b in `bits`) to the stored score of EVERY tile in `mask`, +# in O(NUM_PLANES) whole-board big-integer ops -- cost independent of how many tiles are in `mask`. +def add_bits_to_planes(planes, bits, mask): +for i in bits: # for each set binary digit of the constant c +carry = planes[i] & mask # tiles where digit i overflows +planes[i] ^= mask # add c's i-th digit to all those tiles at once +j = i + 1 +while carry and j < NUM_PLANES: # ripple the carry up through higher planes +new_carry = planes[j] & carry +planes[j] ^= carry +carry = new_carry; j += 1 +# Bitmask of tiles in `cands` whose stored score ≥ thr, computed in one MSB-first pass. +def ge_threshold_mask(planes, thr, cands): +eq, gt = cands, 0 +for i in range(NUM_PLANES - 1, -1, -1): +p = planes[i] +if (thr >> i) & 1: eq &= p # thr digit set: tile must match to stay "equal so far" +else: gt |= eq & p; eq &= ~p # thr digit clear: a set score digit => strictly greater +return gt | eq +Figure14:Thetwomainbit-slicedprimitives.add_bits_to_planesaddsascoreconstanttoanarbitrarysetoftiles +in𝑂(planes)big-integeroperationsregardlessofhowmanytilesareintheset.ge_threshold_maskthresholdsthe +wholeboardagainstascalarinonepassfromthemostsignificantbitdown. +require a delta of at least 2; launchers require at least 5, +reflectingtheirrelativecost. +Figure16:Thislauncherpreventstheenemyfromad- +vancing,asthelauncher’s3x3andthebarrierforma +Figure 15: Valid turret locations. Gunner in red and wall +sentinelinblue. +Wenormallyavoiddamagingenemyconveyortargetsifa +nearby enemy could heal them. However, we have three +Otherwise, we pick the closest preferred or fallback tile. +exceptions: +AmongcandidateswithintwoBFSsteps,wemaximize +• Welocallyoutnumberthethreat(atleast2allybuilder +enemyBFS adj (cand)−myBFS(cand), bots within squared distance 25 versus a single enemy +withintwoBFSsteps). +treating our launcher zones and barriers as enemy- +impassable. • An allied sentinel is in sight, so we damage more than +theycanhealanyway. +Ifthetargetisanenemyconveyorthatavisibleenemycould +heal,wescanadjacentbuildabletilesforalauncherorbarrier • The target’s 3x3 is fully sealed by walls, non-walkable +placementthatstrictlyincreasestheenemy’sBFSdistanceto buildings,orourlauncher3x3s,soenemiescannotreach +theconveyorwithoutworseningourownapproach.Barriers thetargettile. + +===== PAGE 13 ===== +CambridgeBattlecodePostmortem +Heal(ceiling8) This most commonly happens when an enemy places a +Thehealstatedefendsagainstopponentattacksintwoways. launcher to keep our bot away from its attack target. In +First,wehealdamagedallybuildingsadjacenttous.Second, thiscase,weplacealauncherinresponsetokeeptheirbot +wechaseenemybotsthatwanderintoourconveyorzone away.Wewillplaceagunnerinsteadifthetargettileisone +beforetheycanattackourinfrastructure. ofourguardconveyors. +Score returns 7 if any friendly building has more than 2 Route(ceiling7.75) +damage, or if a friendly sentinel and enemy sentinel are +Theroutestatebuildsconveyorlinesfromharvestersbackto +withineachother’sattackranges.Wetreatthesesentinels +thecore.Ittriestoroutefromdead-endconveyors,orphan +asverydamagedsowestaynearitastheytradedamage +harvesters,andorphanfoundries.Astructureisanorphan +withtheenemysentinel.Scorealsoreturns7ifachasetarget +ifithasn’tbeenroutedbackyetandhasatleastonecardinal +existswithintheconveyorzone,definedaswithin8pathing +neighborwecanbuildon. +stepsofaconveyor.Wescore2.5ifachasetargetexistsbut +isfarther. Scorereturns5normally,but7.75(aboveheal’s7)whenany +claimisimportant: +important = expand_chebyshev(enemy_bots, 5) +& ~(my_harvesters | my_foundries) +| feeding_enemy +return 7.75 if important & claims else (5 if claims else 0) +Wewillplacearmouredconveyorsinsteadofnormalcon- +veyorsifwehaveaxioniteavailableandatleast5×thecost +intitanium. +Ifwedon’thaveenoughtocontinuerouting,butthereisan +enemynearby,weplaceabarrieronthedead-end’soutput, +Figure17:Heal. +orthedead-endconveyoritselfifneeded,topreventenemies +fromtakingadvantageofit. +At the end of each turn, we scan adjacent tiles and heal +theonemaximizingdamagetimesafixedtypepriority.We Whenplacingconveyors,weupgradetoarmoredconveyors +consider all very damaged buildings before less damaged whenwehaveatleast5xtheircostintitaniumandwehave +ones,andconsidersixprioritytiers: axioniteavailable. +(1) Core +AttackRoute(override) +(2) Turrets WeaddedattackroutinginKhaosforthefinaltournament. +Instead of routing resources back to our core, the builder +(3) Armoredconveyors,harvesters,andfoundries +extendstheconveyornetworktowardtheopponenttodis- +(4) Regularconveyors rupttheireconomyandinfrastructure.Theoverridetriggers +whenourBFSdistancetotheenemycoreisatmost1.5×our +(5) Barriers,bridges,andsplitters +BFSdistancetoourowncore. +(6) Roads +Intesting,wefoundthataggressiveattackroutingwasusu- +Forchasetargetselection,weremoveenemiesunderattack +allyanetwin.Evenwhentherouteneverreachedtheenemy +byaturretandenemiesthatanotherfriendlybotisstrictly +core,thedamageitcausedtoenemyinfrastructureandarea +closerto.Wethenprefertheclosestsurvivingtarget. +controlwasusuallyenoughtojustifythelossofresources. +Whenchasefailsonpathing,thelauncherfallbackfiresasa +Ifwerealizewecanextendtherouteintoagunner,wedo +counter-launcherresponse: +thatinstead.Weonlyplaceasentinelwhenagunnerwould +for enemy on a damaged friendly conv (unclaimed): besignificantlyworse,suchaswhenweneedtoshootenemy +# T1: placeable cell adjacent to enemy buildingsfrombehindawall. +T1 = adj(enemy) of those four types +# T0: a stand-tile within 8 BFS steps of T1 InFigure19,ratherthansimplyplacingasentineltoshoot +T0 = adj(T1) reachable within 8 thecore,intwooutofthethreeroutes,thebotrealizedthat +pick (enemy, T1) minimizing my dist to T0 +the route could be extended slightly to support a gunner. + +===== PAGE 14 ===== +Wangetal. +Thesecurestatescores7.5ifaclaimsitsonanalready-built +harvester or foundry, since we don’t want to leave these +buildings exposed to enemy turret placements. The state +scores3ifthereisanore,butnoharvesteryet,sincewestill +wanttosecureitbeforeweharvest. +Wegenerallysecureanoreusingguardconveyors.These +inward-facing conveyors are not valid outputs of the ore +tile,sotheyneveractuallycarryresources.However,they +preventtheenemyfromplacingaturretadjacenttotheore, +and they have significantly more health than a road. We +placetheseinsteadofbarrierstoimprovethetraversability +ofourinfrastructureforbuilderbots. +Figure18:AttackRoute. Whilesecuring,wealsocomputetheoptimalrouteandplace +the first conveyor facing outward, instead ofguardingby +facinginward,toslightlyspeedupoureconomy. +The route-target pass detects this pattern via a guard- +conveyormasksothatconveyorswhosetargetisopenore +areexcludedfromtheroutinggraph. +Topreventoscillationsthatwouldotherwiseoccurasthe +builderbotmovestoplaceguardconveyors,whenitisclose +enoughtoseveralunsecuredores,itwillpickthemostse- +curedore(themostsidescovered)withinChebyshevdistance +2,ratherthanjusttheclosestone. +Harvest(ceiling4) +Figure19:AttackRoutewithGunner. +Harvestplacesaharvesteronanoretile.Wepickfrompos- +sible ore tiles that don’t already have a harvester and are +Inthethirdroute,enemyroadsblockedtheextension,soit secured. +optedforasentinelinstead. +Thepossible-oremaskincludestitaniumore,plusaxionite +Secure(ceiling7.5) oreafterround750ifwe’vealreadyharvestedtitanium.It +excludeslandlockedore(orewhosefourcardinalneighbors +Secureandharvestgenerallyexecuteinsequence.Secure +are all ore or unseen, since these are difficult to reach by +firstplacesguardconveyorsonthefourcardinalneighborsof +conveyor).Wealsoavoidexistingallyinfrastructure(con- +anoretile.Oncethetileissecured,harvestplacesaharvester +veyors, bridges, etc.), enemy blocking buildings, and tiles +onit.Thissequencepreventsenemiesfromplacingturrets +underenemyturretthreat. +totakeadvantageofharvesterswithexposedsides. +Figure20:Secure. Figure21:Harvest. + +===== PAGE 15 ===== +CambridgeBattlecodePostmortem +We compute secured() for the entire map using bitmask +operations.Atileissecuredifeverycardinalsideisoffthe +map,awall,oroneofournon-roadnon-markerbuildings. +Ifbuildingtheconveyorlinetothetargetistooexpensive, +we add the tile to a temporary failure cache so we don’t +consideritfor100rounds. +Disrupt(ceiling2) +Disruptplacesbarriersonoretilesoutsideourharvestzone, +toblocktheenemyfromharvestingthem.Weskiporesunder +enemyturretorlaunchercoverage.Thisstateonlyrunswhen +Figure23:InitialExplorationTargets. +wehavesignificantresources(atleastfiveharvestersworth +oftitanium),andactsasacheapwayofdenyingtheenemy’s +economy. +FrontierSampling. Ifnotfollowingtheinitialexploreplan, +Ifthetargettilealreadyhasanenemyroad,wefireonitfirst. +theexplore-targetgeneratorfloodfillsfromthebot’scurrent +Ifit’safriendlyroadormarker,wedestroyitandthenplace +position and other known friendly bot locations. It then +abarrier. +samplesarandomtilefromafrontierringneartheedgeof +theexploredareaoncethefloodterminates: +seeds = me | friendly_bots +frontier = seeds; ring_buffer = deque(maxlen=6) +while frontier and c < 100: +frontier = expand_chebyshev(frontier) & passable +& ~visited +visited |= frontier; ring_buffer.append(frontier); c+=1 +target = random_bit(ring_buffer[0]) # the ring at c-5 +Figure22:Disrupt. Seedingfromteammatesbiaseseachbot’sfrontierawayfrom +whereothersalreadyare,allowingforbetterareacoverage. +Explore(ceiling1) +Turrets +If we have nothing better to do, our builder bots wander +Sentinels and gunners share the same priority system for +towardunexploredterritory. +scoringtiles.Thegoalistoprioritizedisruptingtheenemy +Ourfirstfewbotshavetheirowninitialexplorelogic.Oth- infrastructureasmuchaspossible,byconsideringinorder: +erwise,wewandertowardarandomtilenearthefrontierof +(1) Enemyfoundriesthatfeedatleastoneenemyturret +ourexploredregion. +andnoneofmine. +InitialExploration +(2) Enemy harvesters that feed more than one enemy +Forthefirstfourbuilders,thecoreusesthetileitspawns turretandnoneofmine. +eachbuilderontocommunicatewhichdirectionthebuilder +(3) Enemyturretsthatcanhitoneofmyturrets,plusthe +shouldexplore.Thisletsthecoreorchestrateinitialexplo- +enemyconveyorsthatfeedthemandtheharvesters +rationforbettercoverage. +whosechainreachesthem. +Onthenewbuilder’sfirstturn,itreadsitsownpositionrel- +(4) As(3)butforanyenemyturret,droppingthe“threat- +ativetothecore,recoverstheintendedfacingbycomparing +ensmine”guard. +itstilewiththecore,andreproducesthesameray-endpoint +computationthecoreruns,cappedat12Chebyshevstepsto +(5) Enemybuilderbots +preventthebotfromwanderingofftoofar. +(6) Enemyroads,conveyors,splitters,bridges,orbarriers +Thebotfollowsthattargetuntilitiswithinsquareddistance +cardinallyadjacenttoanyharvesteroneitherteam. +18or30roundshaveelapsed,afterwhichtheslotiscleared +anddefaultexploretakesover. (7) Anyenemyentity,otherthanroadsormarkers + +===== PAGE 16 ===== +Wangetal. +Ifanytargetisadjacenttotwoormoreenemybuilderbots, +weconsideritafteralloftheprioritiesabove,regardlessof +itsnormalpriority. +Withineachpriority,wetiebreakbyconsidering: +(1) Thehighesthealthentitywecouldone-shot +(2) The entity furthest from enemy builder bots (so it +takesthemlongertomovetowardandhealit) +(3) The highest weight entity (varies between gunner +andsentinel,butgenerallyprefersshootingenemy +Figure24:Asentinelone-shotonanenemyharvester. +turrets,theninfrastructure) +(4) Thelowesthealthentity +Gunner +Weignoreconveyors,harvesters,orfoundriesthatfeedour +Gunnerscanonlyshootintheirfacingdirection,sowefire +ownturrets,sowedon’tshootenemybuildingsthatweare +if there is an enemy target in front of us. Otherwise, we +benefitingfrom.Wealsoignorebuildingsifanallybuilder +considerrotatingifweseeanenemybuilding,oranenemy +botisonthesametile,sincethebuilderbotwouldbehit +botstandingonadamagedallyconveyorthatdoesn’thavea +instead. +nearbyallybuilderbottohealit.Thisistopreventenemies +fromcontinuingtofreelyattacktheconveyor. +Sentinel +Rotation requires at least 60 global titanium, so a gunner +Oursentinelscoordinateshotsbywaitingtoshootifthey +doesn’tburnoureconomybyrotatingrepeatedly. +noticeanallysentinelrecentlyshotatargetinvision.This +appliesifweseeatarget’shealthdropandseeanotherally +Launcher +sentinelwiththattargetalsoinvision.Inthiscase,wewait +untilthehealthdropsagainsowecanshootonthesame Launchersthrowenemybots.Alauncherclassifiesitselfonce +turnanddestroyitbeforeenemybuilderbotscanheal. onplacement.Achokepointlauncher(onewithnoenemy +conveyorwithinits3×3)firsttriestoflingtheenemyback +if locked and can_one_shot(locked): +fire(locked) toatiletheyrecentlyoccupied,usingthetrackedper-bot +elif locked: positionhistory.Thisforcesthemtoretracetheirpath. +wait # don't waste 18 dmg +elif select_best gives a one-shot: Ifthatfails,orforoffensiveanddefensivelaunchers,weuse +fire it # one-shots ignore lock +theregion-minimizingthrowheuristic.Foreverylegaldesti- +else: +nation,weChebyshev-floodtheregionthebotcouldnavigate +fire select_best pick +fromthere,usingenemy-POVpassabilityandtreatingun- +seentilesasimpassable.Wethenchoosethedestinationthat +We only coordinate one-taps this way for sentinels with yieldsthesmallestreachableregion.Wetiebreakbygreatest +a higher id than the other ally sentinel in vision. This is wall-onlyBFSdistancefromtheseedsetconsistingofevery +becauseentitiesactinincreasingidorder,sothehigher-id conveyorplusthelauncheritself: +sentinelwillalwaysbeabletoreacttothelower-idsentinel’s +for dest in legal_launch_tiles: +shotwithinthesameturn,butnotviceversa. +region = flood_chebyshev(dest, enemy_passable +& seen) +Oursentinelswillconsiderself-destructingiftheyarenot size[dest] = popcount(region) +throw to argmin (size[dest], -wall_bfs_dist(dest)) +adjacenttoaharvesterandthereisnoenemybuilderbot +nearby. Theywill self-destructafter16 turnsifthey have +noammo,orafteronly4turnsiftheyalsohavenopossible Theeffectistoeitherkeeptheenemyoutofourterritory,or +upstreamfeeder. keepthemawayfromatilewearetryingtodisrupt. + +===== PAGE 17 ===== +CambridgeBattlecodePostmortem +ChokepointDetection Theoldversionreliedonpolygonunion,difference,intersec- +Wealsohaveasignificantamountoflogicgearedaround tion,andboundaryextractionfromexternallibraries.The +chokepoint detection and subsequent launcher or barrier runtimeversioninsteaddoeseachstepdirectlyonthemask: +placement,andsowilldedicatethenextfewpagestothat. scanlinepolygonfillfortheanalysisregion,blockfillingfor +Chokepointdetectionstartsrunningoncewehavelearned walltiles,flood-fillcomponentfilteringforsmalldiscarded +enoughofthemap,andthencontinuesrunningintheback- obstacles,andbooleansubtractionforfreespace.Sincethe +groundthroughanincrementalsave-statefortherestofthe +mapsareatmost50×50,evenamoderatelyupsampledmask +game. issmallenoughforpurePython. +Thegoalistoproduceasmallsetoftacticallyusefultiles +Once free space is known, the boundary is extracted by +whereasinglebarrierorlaunchercanchangetheconnec- +scanningforfree/non-freetransitions.Consecutivecollinear +tivity of the map. In practice, these are narrow corridors +boundary pixels are merged into longer axis-aligned seg- +between larger regions, especially corridors that separate +ments,whichkeepsthelatersamplingstepmuchsmaller +ourcontrolledterritoryfromtheopponent’sside.Duetofea- +thantreatingeveryrasteredgeindependently: +turessuchasbarrierwalkthroughandalaunchereffectively +blockinga3×3area,theseregionsbecomechokepointsthat segments = boundary_segments_from_mask(free_mask, scale) +onlyourbotscanpass. +# Internally: +# - scan top/bottom exposed edges row-wise +# - scan left/right exposed edges column-wise +# - merge adjacent horizontal and vertical runs +Thosemergedboundarysegmentsbecometheinputsites +forasweep-lineVoronoiimplementation.Infloatmode,the +runtimechokepointersetsthenumericbackendtoordinary +Figure25:Chokepointbarrierwalkthrough. PythonfloatsratherthanDecimal.Thislosesarbitrarypre- +cision,buttheinputsarealreadyquantizedtomap/sub-tile +SimplifiedGeometryCore coordinates,andthespeedupislargeenoughtomatterfor +all-mapiteration. +ThestandalonechokepointlogicneededtorunwithoutSciPy, +Shapely,NumPy,orcompiledgeometrylibrariessincethey set_foronoi_numeric_mode("float") +werebannedforthiscompetition.Hence,acustomgeometry +points = collect_boundary_samples(sample_spacing) +engine was needed. The base library was vendored from +vor = Voronoi(Polygon(analysis_polygon)) +theForonoiPythonlibrary,butcompletelyrestructuredand +vor.create_diagram(points=points) +optimizedforthepurposesofthiscompetition. +for edge in vor.edges: +Thecoreideawastoreplaceheavyweightpolygonopera- +p1 = edge.origin() +tionswithasmallraster-backedgeometrylayer.Themapis p2 = edge.twin.origin() +firstliftedintoahigher-resolutionbooleanmask,whereeach if segment_is_inside_free_space(p1, p2): +gametilebecomesasmallscale×scaleblock.Thescaleis add_raw_edge(p1, p2) +derivedfromtherequestedsamplespacing,givingenough +sub-tileprecisionforgeometrywhilekeepingthewholemap +tiny: Theruntimealsoreplacesgeometriccontainmentqueries +with mask containment. A candidate Voronoi edge is ac- +scale = raster_scale_from_spacing(sample_spacing) +cepted only if several sampled points along the segment +remaininsidethefree-spacemask: +analysis_mask = build_analysis_mask(rows, cols, scale, +analysis_polygon) +def segment_is_inside_free_space(a, b): +obstacle_mask = build_obstacle_mask(walls, rows, cols, +steps = max(5, ceil(length(a,b) * scale * 2)) +scale, analysis_mask) +for k in range(1, steps): +p = lerp(a, b, k / steps) +kept_obstacles, discarded = split_obstacle_mask_by_area( +if not point_is_inside_free_space(p): +obstacle_mask, min_area * scale * scale) +return False +return True +free_mask = build_free_mask(analysis_mask, kept_obstacles) + +===== PAGE 18 ===== +Wangetal. +Finally,eachVoronoivertexneedsaclearanceradius,which changesenoughtoinvalidatethem.Thisiswhyourbuilder +isthedistancetothenearestwall/free-spaceboundary.In- botstimeouteveryturn. +steadofaskingageometrylibraryfordistance-to-boundary, +theruntimestoresthemergedboundarysegmentsintwoflat MapReduction +lists:verticalsegmentsandhorizontalsegments.Radiusthen +Thefirststepistodecidewhichpartofthemapweactu- +becomesadirectminimumoversquaredpoint-to-segment +allyneedtoanalyze.Becauseeverymapissymmetric,once +distances: +symmetryissolvedweonlyanalyzeourhalfofthemapand +best = INF mirror the result. This cuts the work by roughly a factor +oftwoandalsopreventsduplicatechokepointsfrombeing +for x0, y1, y2 in vertical_boundaries: reportedonbothsidesofthesamesymmetriccorridor. +dx = x - x0 +dy = clamp_distance_to_interval(y, y1, y2) if symmetry == VERTICAL: +best = min(best, dx*dx + dy*dy) analysis_poly = half_map_containing(my_core) +elif symmetry == HORIZONTAL: +for y0, x1, x2 in horizontal_boundaries: analysis_poly = half_map_containing(my_core) +dy = y - y0 elif symmetry == ROTATIONAL: +dx = clamp_distance_to_interval(x, x1, x2) analysis_poly = clipped_by_perpendicular_bisector( +best = min(best, dx*dx + dy*dy) my_core, enemy_core) +else: +radius = sqrt(best) analysis_poly = full_map +Thisfloatruntimeisintentionallylessgeneralthanafullge- +Theinputtothegeometricpipelineisthesetofknownwalls +ometrykernel.Itonlyneedsaxis-alignedtilemaps,clipped +intersectedwiththisanalysispolygon.Unknowntilesare +analysispolygons,boundarysampling,free-spacecontain- +treatedconservatively:wedonotwanttoplanapermanent +ment,andclearanceradii.Byspecializingtoexactlythose +blockeraroundacorridorthatmaylaterturnoutnottoexist. +operations, the chokepointer stays fully pure Python and +Symmetry broadcasts help here because they let us infer +remainsfastenoughtorun. +far-awaywallsbeforephysicallyexploringthem. +OnlineSave-Stating +VoronoiSkeleton +Thisseparationwasimportant.Thegeometrycodeisrela- +ThemainversionofthedetectorusesaBroodwarTerrain +tivelycomplex,whereasthein-gameusagehastobesimple +Analyzer(BWTA)-adjacentmedial-axisapproximation.We +androbust.Thebuildershouldnotcarewhetherachoke- +samplepointsalongtheboundaryofthefreespace:outer +pointcamefromaVoronoiedge,aprunedgraphchain,or +mapboundary,wallperimeters,andanyobstacleholes.Then +amergedregionboundary.Itonlyneedstoknowwhether +werunaVoronoidiagramoverthosesamples.Voronoiedges +standingnearatileandspendinganactionthereislikelyto +thatlieinsidefreespaceapproximatethemedialaxisofthe +improveourposition. +traversablemap. +Atahighlevel,thepipelineis: +Theintuitionisthatapointonthisgraphisequallyfarfrom +known_walls = map_info.wall_mask_from_seen_and_symmetry() twoormoreobstacleboundaries.Wideopenroomsproduce +high-radiusgraphregions,whilenarrowcorridorsproduce +state = chokepoint_state.resume_or_init(known_walls) +low-radiusgraphchainsbetweenthoseregions. +state.step_until_budget() +samples = [] +if state.finished: for segment in free_space_boundary: +for choke in state.blocker_candidates: samples.extend(sample_segment(segment, spacing=1.0)) +register_chokepoint_tile(choke.tile, +choke.kind, +diagram = voronoi(samples) +choke.radius) +for edge in diagram.edges: +Theactualcomputationistooexpensivetorunfromscratch if edge.is_finite() and edge_inside_free_space(edge): +graph.add(edge) +insideasinglebuilderturn,sotheanalyzerisincremental. +Eachcallperformsaboundedamountofworkandstores +theintermediatestateglobally.Onceitfinishes,theresult- Foreachgraphvertex,wecomputeaclearanceradius:the +ingchokepointtilesarecacheduntiltheknownwallmap distancefromthatvertextothenearestfree-spaceboundary. + +===== PAGE 19 ===== +CambridgeBattlecodePostmortem +Unpruned Pruned +Figure27:GraphPruning +RegionNodes +Afterpruning,weidentifyregionnodes.Aregionnodeis +Figure26:Voronoion"Cubes" +eitheratopologicaljunctionorendpoint,oralocallymaxi- +malradiuspoint.Thelocallymaximalpointsareimportant +Thisradiusisthemainscalarusedbythelaterpruningand becausealongcorridorbetweentworoomsmayhaveno +chokepointextractionpasses. graphjunctionattheroomcenter.Themaximum-clearance +pointmarkstheroom-likepartofthatchain. +for v in graph.vertices: +radius[v] = distance(v, free_space.boundary) +Asmallradiusmeansthevertexliesinatightcorridor.A +largeradiusmeansthevertexliesinanopenarea.Choke- +pointsarenotsimplythesmallest-radiusvertices,because +wallcornersanddead-endbranchesalsohavetinyradius. +Theyarethesmallest-radiusverticesonmeaningfulpaths +betweenlargerregions. +GraphPruning +TherawVoronoigraphcontainsmanybranchesthatpoint +into wall corners or small pockets. These are not useful +chokepoints,becauseblockingthemdoesnotseparatetwo +importantregions.Weprunethesebrancheswithasimple +radiusmonotonicityrule. +Leavesareremovediftheyarenarrowerthantheirparent. +Figure28:RegionNodes +Repeatingthispeelsoffbranchesthatrunfromopenspace +intoadeadendorwallcorner,whilepreservingchainsthat +connecttwolargerareas. for v in graph.vertices: +if degree(v) ≠ 2: +active = all_vertices +regions.add(v) +queue = leaves(graph) +elif radius[v] ≥ REGION_MIN_RADIUS: +if no nearby vertex has radius ≥ radius[v]: +while queue: +regions.add(v) +leaf = queue.pop() +parent = only_neighbor(leaf) +The“nearby”testusesChebyshevdistanceboundedbythe +if radius[leaf] < radius[parent]: candidate’sownradius.Thismakestheregiontestscalewith +remove leaf from graph +localfeaturesize:inalargeroom,nearbymaximasuppress +if degree(parent) == 1: +queue.push(parent) eachother,whileinanarrowcorridor,smalllocalbumpsdo +notbecomeseparateregions. + +===== PAGE 20 ===== +Wangetal. +ChokeExtraction Themainrulecomparesthechokeradiusagainsttheradiiof +Onceregionnodesareknown,chokepointsareextractedby thetworegionsitconnects.Ifthebottleneckiscloseinsize +walkingeverychainbetweentwoadjacentregions.Along tothesurroundingregions,thenitisnotreallyabottleneck, +eachchain,thevertexwithminimumradiusisthebottleneck. sothetworegionsshouldbeconsideredonelargerregion: +for start, choke, end in chokes_by_decreasing_radius: +r_start = find(start) +r_end = find(end) +if r_start == r_end: +remove(choke) +continue +choke_r = radius[choke] +small_r = min(radius[r_start], radius[r_end]) +large_r = max(radius[r_start], radius[r_end]) +if (choke_r > ratio_small * small_r +or choke_r > ratio_large * large_r): +union(r_start, r_end) +remove(choke) +Thisremovesmanyduplicatechokepointsinopenareas.For +Figure29:Chokepoints...? example, if two large chambers are connected by several +nearbyshallowdipsinradius,thewidestdipsaremerged +awayfirst,leavingonlythenarrowestmeaningfulseparator. +Uhoh.That’salotofchokepoints,includingsomewecannot +feasibly block. We can get around this issue by merging +We also added a second rule for the common case where +nearby regions together to remove some of the duplicate +asmallintermediateregioniscreatedbetweentwochoke- +chokepointswesee. +points.Ifaregionhasexactlytwoactivechokepointsandthe +The merge pass treats the region graph as a set of region largerofthemisstilltoowiderelativetotheregionitself,we +nodesconnectedbycandidatechokepoints.Achokepointis mergethroughit.Thispreventstinyartificialregionsfrom +onlyusefulifitissubstantiallynarrowerthantheregionsit producingpairsofredundantblockers: +separates;otherwiseitismorelikeaharmlesslocalwiggle +# after ignoring already-removed chokes +in the medial graph than a place where placing a blocker +for region in (r_start, r_end): +changesmovement. +active = active_chokes_touching(region) +Weprocesscandidatechokepointsfromwidesttonarrowest +if len(active) == 2: +andmaintainaunion-findoverregionnodes.Ifachokeis +if max(radius[c] for c in active) > +toowiderelativetoeitherofthetwoadjacentregions,we ratio_two_choke * radius[region]: +mergethoseregionsanddeletethechoke: union(r_start, r_end) +remove(choke) +parent = {region: region for region in region_nodes} break +def find(v): +while parent[v] ≠ v: Aftermerging,anyremovedchokeverticesaredeletedfrom +parent[v] = parent[parent[v]] +thechokeset,androundedblockerplacementsarerecom- +v = parent[v] +putedfromtheremainingchokes. +return v +for region in regions: +def union(a, b): for edge in outgoing_edges(region): +ra, rb = find(a), find(b) +path = walk_until_next_region(edge) +if ra == rb: +return if path reaches another region: +if radius[ra] ≥ radius[rb]: choke = argmin(path, key=radius) +parent[rb] = ra +if radius[choke] ≤ MAX_CHOKE_RADIUS: +else: +chokes.add((region, choke, other_region)) +parent[ra] = rb + +===== PAGE 21 ===== +CambridgeBattlecodePostmortem +Thegeometricpointisroundedbacktoabuildabletile.Can- +didateswhoseroundedfootprintsoverlapareclustered,and +wekeepthetightestchokeineachcluster. +for choke in chokes: +tile = round_to_tile(position[choke]) +kind = blocker_kind(radius[choke]) +if kind: +candidates.append((tile, kind, radius[choke])) +clusters = overlap_clusters(candidates) +for cluster in clusters: +keep min cluster by radius +UsingChokepointsIn-Game +Thechokepointoutputisconsumedpassivelybybuilders.We +donotcreateadedicated“chokepointstate”withahighscore +Figure30:AfterMerging +ceiling,becauseblockingterrainisusefulopportunistically +butshouldnotoverrideobviousattacks,repairs,orrouting +Thisgivesusagraph-levelinterpretationofachokepoint:it work.Instead,afterthemainstateruns,thebuilderchecks +isthenarrowestpointonaroutebetweentwolargerspaces. whetheritcancheaplyplaceachokepointblockernearby. +Thatdistinctioniswhatpreventseverywall-adjacentpixel +def try_passive_chokepoint_action_only(): +frombeingtreatedasimportant. if action_on_cooldown: +return False +Wethenconvertthegeometricchokeintoanin-gameblocker. +Verynarrowchokescanbeblockedbyasinglebarrier.Wider for choke in nearby_chokepoints(): +chokesusealauncher,becausealaunchercontrolsalarger if already_blocked(choke): +effectiveareabythrowingenemybuildersbackoutofthe continue +if can_build(choke.kind, choke.tile): +corridor. +build(choke.kind, choke.tile) +def blocker_kind(radius): return True +if radius ≤ WALL_CHOKE_RADIUS: +return BARRIER return False +if radius ≤ LAUNCHER_CHOKE_RADIUS: +return LAUNCHER Launchersbuiltatchokepointsuseaspecializedthrowpolicy. +return None Insteadofmaximizingrange,weattempttogettheopponent +builder stuck walking back into the chokepoint infinitely. +Wehencelaunchtothelastnthpositiontheywereseen. +if launcher_is_chokepoint_launcher: +enemy = best_enemy_builder_in_range() +dest = position_from_enemy_history(enemy) +if can_launch(enemy, dest): +launch(enemy, dest) +Thisisespeciallyeffectivewhentheenemyneedsabuilderto +healanattackrouteorrepairabrokenconveyor.Throwing +thebuilderbackwardturnsonelauncheractionintoseveral +turnsoflostwalkingtime. +Forinstance,thefigureabovedepictsachokepointlauncher +blockingahordeofopponentbotsfromhealingorreplac- +ingadefensivebuildingthatwasjustbroken;withoutthe +chokepointlauncher,theycouldeasilyhavepreventedthis. +However,ourbuilderisabletomovethroughfreelyand,a +Figure31:ClassifyLaunchers +fewturnslater,placeaturret. + +===== PAGE 22 ===== +Wangetal. +byitsskeletonandlocalradius.Thesurroundingfree-space +medial axis changes only slightly in these cases, but the +numberofgeneratedsitesdropsbyaconstantfactor. +for wall_component in components: +if is_large_compact_component(wall_component): +sites.extend(sample_wall_medial_axis(wall_component)) +else: +sites.extend(sample_wall_perimeter(wall_component)) +Weonlyapplythisreductionincaseswhereitisunlikelyto +removeameaningfulfeature.Theoptimizationistherefore +conservative:itismainlyawaytoavoidoversamplinglarge +blocksofimpassableterrain. +InGame Tooling +PerformanceOptimization +Figure32:ChokepointBlocksOpponent +Runtimeoptimizationofourbotwascrucialnotjusttofit +withinthe2000𝜇sturnbudget,butalsotofreeuptimefor +IncrementalExecution ourchokepointcalculationstoruninthebackground. +Thefulldetectoristooexpensivetorecomputeeveryturn. Table 2 shows that all of our top ten runtime callers deal +Thein-gameversionthereforerunsasaresumablejob.Each withmapinfoorpathfinding. +phasestoresenoughstatetocontinuelater:obstaclecompo- +nents,boundarysamples,Voronoiedges,prunedgraphstate, Bitmasks +andfinalblockercandidates. Oneofourmainoptimizationswasusingbitmaskstostore +mapinfo,sinceusingbitoperationsonPythonbigintegers +while budget_left() and job.phase ≠ DONE: +if job.phase == BUILD_GEOMETRY: wassignificantlyfasterthaniteratingoverorindexinginto +job.build_some_components() arrays. +elif job.phase == SAMPLE_BOUNDARY: +job.sample_some_edges() Forexample,wecaniterateoverallsetbitsinamaskusing +elif job.phase == BUILD_GRAPH: thecanonicaltwo’s-complementLSBidiom: +job.process_some_voronoi_edges() +elif job.phase == PRUNE: while mask: +job.prune_some_leaves() lsb = mask & -mask # isolate lowest set bit +elif job.phase == EXTRACT: n = lsb.bit_length() - 1 # its position +job.extract_some_chokes() # ... use Position(n % W, n // W) ... +mask ^= lsb # clear and continue +Thismakesthecomputationcompatiblewiththe2000𝜇sunit +Thus,ifwebuildamaskcontainingpossibletargettiles(for +budget.Abuildercanspendasmallsliceoftimeadvancing +exploretargets,turretplacements,etc.),thetimecomplexity +theglobalchokepointjobwithoutriskingtimeout.Oncethe +is proportional to the number of set bits rather than the +jobfinishes,allbuilderssharethesameresult. +numberoftiles. +Medial-AxisWallReduction +LocalVariableHoisting +Oneoptimizationweaddedlaterwasreducingcertainwall +Pythonattributeandgloballookupsarenotfree,andsomeof +polygonstotheirmedialaxisbeforefeedingthemintothe +ourinnerloopsrunhundredsofthousandsoftimesperturn. +BWTA-styleruntime.Theoriginalboundary-samplingap- +Hence,everyhotfunctionbindsthemoduleandcontroller +proachsampleseveryexposedwallperimeter.Thisisaccu- +membersitneedstolocalsoncebeforetheloop: +rate,butlargesolidwallblobscancontributemanyperimeter +segmentswhosefinedetailhaslittlestrategiceffect. +Forsufficientlythickorcompactwallcomponents,wecan +replacethecomponent’sfullperimeterwithamuchsmaller +medial-axisrepresentative.Conceptually,insteadofdescrib- +ingeverypixelofthewalloutline,wedescribethewallmass + +===== PAGE 23 ===== +CambridgeBattlecodePostmortem +Table2:Thetenlargestruntimeconsumers(ignoringchokepointconsumers),rankedbytotalselftime.Selftimes +areadditiveandsumtototalCPU,so%iseachfunction’sshareofthetotalselftime(≈6,483msover6,070profiled +turnsofonemap);thelistedtenaccountfor≈35%.𝜇s/callismeanselftimeperinvocation. +Function Calls Self(ms) 𝜇s/call % +map_info:update_at 470,546 883.9 1.9 13.6 +map_info:update 6,070 336.7 55.5 5.2 +pathing:_claim_zone_on_passable 3,412 222.4 65.2 3.4 +map_info:_compute_route_targets 6,716 218.0 32.5 3.4 +pathing:bfs_move 3,655 123.1 33.7 1.9 +map_info:_carrying_expand 5,321 117.8 22.1 1.8 +Controller.get_tile_building_id 414,358 113.2 0.27 1.7 +map_info:expand_chebyshev 71,745 111.2 1.55 1.7 +pathing:_closest_impl 15,170 84.3 5.56 1.3 +map_info:update_move 2,752 76.6 27.8 1.2 +Furthermore,afteramove,update_movediffstheoldandnew +# bfs_move, before the frontier loop iterates: +visionmasksandcallsupdate_atonlyontilesthatbecame +bm_et = map_info._bm_et; +nlc = map_info._not_left_col; visible: +board = map_info._board_mask; +for tile in nearby: +bcost = barrier_cost; +bit = 1 << (tile.x + tile.y * W) +# update_at, binding hot Controller methods to locals: +new_visible |= bit +get_tile_env = rc.get_tile_env +if not (old_visible & bit): # only the delta +get_tile_building_id = rc.get_tile_building_id +update_at(tile) +get_hp = rc.get_hp +Thisreducesthenumberoftileswescanaftermovingfrom +alltileswithinvisiontoonlythenewlydiscoveredtiles. +Integer-IndexedTables +cambcenumsarenotinteger-valued,sousingthemasdictio- TTLCaches +narykeysorinfrozensetmembershiptestscostsahashper +Statesthatdependonexpensivecalculationstofindtarget +access.map_infoconvertseveryenumtoasequentialindex +tiles remember the tiles that failed. We use Time to Live +onceatimportandthereafteruseslistindexingandboolean (TTL)cachesthatstoretargettileswewanttoskipfor100 +lookuptablesinsteadofdictionarylookups. rounds,sowedon’twastecomputetryingtocalculateifa +recentlyfailedtargettileshouldbeactedonagain. +CachingandMemoization Debugging +Manyofourderivedbitmasks,calculatedtargets,etc.,are log.pybindslogatmoduleloadtooneoftwodefinitions +memoizedonthemapinfostructurestheydependon.We dependingonasingleboolean: +also memoize some calculations irrespective of local map +DEBUG_LOGGING = False +changes.Theharvestzone,whichisaVoronoifloodfillfrom +if DEBUG_LOGGING: +ourcoreagainstthepredictedenemycoreusedtoapproxi- +def log(*args, **kwargs): print(*args, **kwargs) +matesafeharvestlocations,onlyrunswhensymmetrypre- else: +dictionsupdate.Thecolumnandrowguardmasksandthe def log(*args, **kwargs): pass +eightper-facingdirectiontemplatesforsentinelsarelikewise +builtatinitandnotupdatedagain. Everylog(...)callsitethusbecomesano-opfunctioncall +whenweturntheflagoffbeforesubmittingourbot. +IncrementalUpdates Results +Weprioritizeupdatingourmapinfostructuresratherthan OurtournamentresultsaresummarizedinTable3.Sprints +fullyrebuildingthem.Forexample,update_atmaintainscon- weretheweeklytournamentsheldthroughoutthecompe- +veyoroutputandreverseconveyoroutputbitmasksasbuild- tition,whereteamscouldevaluatetheirperformanceand +ingsareupdated,ratherthanrescanningtheentiremap. gainabetterunderstandingofthemeta.TheInternational + +===== PAGE 24 ===== +Wangetal. +Qualifierswasatournamentforthetopeightteamsoutside +theUK,offeringachancetoadvancetotheGrandFinals. +TheGrandFinalswastheculminatingin-persontournament +for the top eight international and top eight UK teams to +watchthematchesplayout. +TheMemeandPongtournamentswerefor-funtournaments +heldaftertheGrandFinals.TheMemetournamentfeatured +severaledge-caseandtrollmaps.ThePongtournamentin- +volvedmin-maxingaxionitecollectionbyhardcodingstrate- +gies specifically for the Pong map. This map was unique +inthatthegamewouldendinaforceddrawifbothsides +avoidedplacingroadsnearthebarrierinthemiddle,since +thenneithersidecouldreachtheopponent’score. +Table3:TournamentResults. +Tournament Date Result +SprintOne 3/22 Top128 +SprintTwo 3/28 Top8 +SprintThree 4/5 Top4 +SprintFour 4/11 Top8 Figure34:TopeightseededteamsfortheGrandFinals. +SprintFive 4/19 Top4 +InternationalQualifiers 4/20 3rd oneslistedatthebeginning(suchasHermesandHephaestus, +GrandFinals 5/4 1st along with sub-versions of all of our bots) and were still +MemeTournament* 5/9 Top8 addinginnewstrategieslikeattackroutejusthoursbefore +PongTournament* 5/9 2nd thefinalsubmissiondeadline. +*For-funtournamentsplayedwithdifferentrule-sets. Overall,ourfinalsolutionisdifferentfrommostBattlecode +solutions,whichtendtohavepersistentstatesanddefined +Figure33showsourEloprogressionovertime.Wewentinto statetransitions.However,webelieveourmemorylessap- +theGrandFinalsasthefirstseed(Figure34showsthetop proachwasoneofthemainfactorsinourbotbeingvery +eightseededteamsbeforetheGrandFinals). dynamicandadaptable.Italsoallowedforalotofcomplex +behaviortoemergewithouthardcodingstrategies,which +madeourbotlesspronetooverfitting. +Intheend,ourteamhadalotoffuncompeting,andwe’re +alreadylookingforwardtothenextcompetition. +Figure33:Eloratingoverthecourseofthecompetition. +Conclusion +Welearnedalotoverthecourseofthecompetitionthrough +trialanderror.Wehadmanymorebotversionsoutsideofthe \ No newline at end of file diff --git a/tmp/pdfs/pantheon_postmortem/postmortem.txt b/tmp/pdfs/pantheon_postmortem/postmortem.txt new file mode 100644 index 0000000..c5ca16b --- /dev/null +++ b/tmp/pdfs/pantheon_postmortem/postmortem.txt @@ -0,0 +1,1679 @@ +===== PAGE 1 ===== + + + + + + + Cambridge Battlecode Postmortem + + + Team Pantheon + + Derek Wang Yufan Wang + UniversityofOxford GeorgiaInstituteofTechnology + + Alex Thummalapalli Ioannis Pantidis + GeorgiaInstituteofTechnology DelftUniversityofTechnology + + + Overview Eachmatchisplayedonarectangularmap,ranginginsize + WecompetedintheinauguralCambridgeBattlecodeasTeam + from20×20to50×50andguaranteedtobehorizontally, + Pantheon, consisting of Derek Wang, Yufan Wang, Alex vertically,orrotationallysymmetric.Eachteamstartswith + Thummalapalli,andIoannisPantidis. asinglecoreunitthatcanspawnbuilderbots,eachrunning + anindependentcopyofthesameprogram. + Weenteredthecompetitionwithmixedlevelsofexperience. + For three of us, this was our first year doing Battlecode. Thoughthemapisinitiallycomposedoffourbasictiletypes + Ultimately,weplacedfirstintheGrandFinalswithourbot, (empty,titaniumore,axioniteore,walls),builderbotscan + Khaos.Thefinalbot,alongwithourtooling,canbefoundin constructnewinfrastructurethroughoutthegamesuchas + ourGitHubrepository. conveyors,turrets,androads. + + + + + + + + + + + + Figure2:Mapfeatures:titaniumoreinblue,axionite + oreinorange,andwallsinblack. + Figure 1: A Grand Finals match between Pantheon + (gold)andsomethingelse(silver). + Resources + GameOverview Titaniumistheprimaryresourceandisusedtobuildbuild- + ings,heal,andasammunitionformostturrets.Rawaxionite + CambridgeBattlecodeisasix-weekcompetitioninwhich + iswhatisminedfromaxioniteore.Rawaxioniteandtitanium + teamsdesignanddevelopalgorithmstoautonomouslyplay + canbecombinedtocreaterefinedaxionite(typicallyreferred + aturn-basedstrategygame.Thisyear’sgameinvolvedbal- + toassimply“axionite”),whichisapremiumresourcewith + ancingresourcemanagement,mapcontrol,andcombatby + morelimiteduses,althoughitcanbeconvertedbackinto + dynamicallyroutingconveyorlinesacrossa2Dmap. + titaniumorusedfortiebreaks. + ThoughitwasinspiredbyMITBattlecode,thiscompetition + featuredsomekeydifferences;notably,aswitchfromJava + toPython,newtimelimitconstraintsinplaceofbytecode + counting,andalargejumpincomplexity. + +===== PAGE 2 ===== + + + + + + Wangetal. + + + + + Core BuilderBot Conveyor ArmouredConveyor Bridge Splitter Harvester + + + + + + Foundry Gunner Sentinel Breach Launcher Road Barrier + Figure3:Gameentitiesandbuildings. + + Core EconomicBuildings + + Eachteamstartswithacore,placedsymmetricallyonoppo- + • Harvesterscan be builton titaniumor axioniteore to + sitesidesofthemap.Thecoreisanimmobile3×3building. + harvestresources.Theyoutputresourceseveryfourturns + toacardinallyadjacentbuilding,likeaconveyororturret. + Itcanspawnbuilderbotunitsononeofitstilesonceper + turn,anditiswalkableonlybyalliedbuilderbots. • Foundriesconverttitaniumandrawaxioniteintorefined + axionite.Theyacceptinputfromanysideandwillout- + Resourcesmustbedeliveredtothecorebeforetheybecome + putrefinedaxioniteafterconsumingbothtypesofinput + globallyaccessibletoallunits.Thecorealsohastheability + resources. + toconvertaxionitetotitaniumina1:4ratio. + Turrets + BuilderBots + Turretsaretheprimarywayofattacking.Allturrettypes + Theprimaryunitinthegameisthebuilderbot.Itistheonly + exceptlaunchersrequireammotofire,meaningresources + unitthatcanmoveorconstructbuildings. + mustbefedintothemfromanon-facingside.Therearefour + Builderbotscanmoveonceperturntoanadjacenttilewitha typesofturrets: + walkablebuildingonit.Theycanalsoperformanactiononce + • Gunnersarehigh-DPS,low-rangeturretsthatcanonly + perturntohealanadjacententity,constructabuildingon + shootthefirsttargetinrange.Theycanturnbyspending + anadjacenttile,orattackabuildingonthetiletheyoccupy. + titanium. + Builderbotscanalsofreelydestroyadjacentallybuildings + withoutusinguptheiraction. • Sentinels are lower-DPS turrets with a long and wide + range. Unlike gunners, they can shoot any tile within + BuilderbotvisionextendstosquaredEuclideandistance20. + theirrange. + Conveyors • Breachesareveryhigh-DPSturretsthatdealsplashdam- + age.However,theycanonlyuserefinedaxioniteasammo, + • Conveyors send resources to the tile in front of them. + sotheyareseldomused. + Theyacceptresourcesfromthethreecardinalnon-facing + sides. • Launchersareutilityturretsthatcanpickupandlaunch + adjacentbuilderbotsoneitherteamtoanywalkabletile + • Armouredconveyorsactlikeregularconveyorsbuthave + intheirvision. + morehealthandcannotbedamagedbybuilderbots.They + costbothtitaniumandaxionitetobuild. MiscellaneousBuildings + • Bridges are more expensive than conveyors but have • Roadsarecheap,walkablebuildings.Sincebuilderbots + moreoutputoptions:anytilewithinsquareddistance≤9. + cannotwalkonemptytiles,aroadmustbeplacedbefore + Theyacceptresourcesfromanyside. thattileispassable. + • Splittersonlyacceptresourcesfrombehind,buttheysend • Barriers are cheap, high-health buildings that block + outputsequallytothethreeremainingcardinalsides. buildermovement. + +===== PAGE 3 ===== + + + + + + CambridgeBattlecodePostmortem + WinningtheGame + + Therearetwowinconditions:eitherdestroyingtheenemy + coreorwinningbytiebreakafter2000roundshavepassed. + Tiebreakersaredeterminedinorder: + • Axionitecollected + + • Titaniumcollected + • Harvestersalive + + • Axionitestored + • Titaniumstored + + BotSummary + We went through several substantial iterations and even + Figure5:Artemis(Gold)vsStarter(Silver) + entirerewritesoverthecourseofthecompetition. + Starter + Artemis also used a defensive launcher network both to + protectourconveyorsandharvestersandtohelpallybots + quicklymoveacrossthemap. + + Demeter + + + + + + + + + + + Figure4:Starter(Gold)vsStarter(Silver) + + + WestartedwithabasiceconomybotwithA*pathfinding, + conveyorrouting,andminimalattacking. + Artemis Figure6:Demeter(Gold)vsArtemis(Silver) + + Our initial baseline evolved into a launcher-centric bot + namedArtemis. + AftermergingwithTeamFood,webug-fixedAlex’sbotand + Adedicatedbuilderbotwouldrushtheenemycoreusing portedoverourmapinfo,pathfinding,andgunnerlogicfrom + launcherstolaunchitselfforwardinhopesofdisruptingtheir Artemis. This defensive bot solely focused on developing + earlyeconomyveryquickly.Thenitwouldfindaconveyor infrastructure,butalsohaddynamicturretplacementlogic + toattempttoattackandreplacewithaturrettodestroythe thatwouldrepurposeourownconveyorlinesiftheywere + enemycore.Itwouldplaceadditionallauncherstoflingaway neededmorefordefense.However,wesoonhitaplateau + anyenemybuilderbotattemptingtohealthetiletherusher duetoovercomplicatedcodeandstrategiesandhadtobegin + wasattacking. arewrite. + + + + + +===== PAGE 4 ===== + + + + + + Wangetal. + + newsystemusedfactorslikeclosestbuildertoatargettile + todecidewhethertopursueatask. + Khaos + + + + + + + + + + + + Figure7:Lethe(Gold)vsDemeter(Silver) + + Lethe + Keepingonlyourmapinfoandpathfindingmodules,wefully + rewroteourbotwithamemorylessarchitecture.Theidea Figure9:Khaos(Gold)vsHades(Silver) + behindthememorylesssystemwastohaveourbotrecom- + puteinformationeveryturnbasedonglobalmapknowledge, Adaybeforethefinalsubmissiondeadline,wemergedina + ratherthanretainingstalestrategiesorstates.Thisallowed brand new chokepoint detection system and attack route + ourbottobehighlydynamicandmakemuchmoreoptimal strategy that greatly improved our win rate. We named + decisionseachturn,andwefinallystartedwinninggames thisbotKhaosduetoitsunpredictableanddynamicnature, + againsttopteamslikeBlueDragon. whichmadeitdifficultforopponentstocounter. + Hades StrategicOverview + TherestofthispostmortemdocumentshowKhaosworks. + We’llstartwithgeneralstrategies,thencovertechnicalim- + plementationfromthemapinfolayerthroughpathfinding, + states,turretlogic,andruntimeoptimization. + + MemorylessArchitecture + Ourbitmaskedmapinfolayerisfastenoughforustorecom- + putekeydecision-makingandplanninglogicfromscratch + eachturn.Ratherthandefiningstatetransitionsbasedon + previousstates,wescoresevencandidatestateseachturn + basedoncurrentmapknowledgeandexecutethehighest + scoringstate. + Theintentisforlong-termbehaviortoemergefromevery + builderactingoptimallyonitscurrentlocalknowledge.This + makeseachbot’sactionsmuchmoredynamic,sincethey + Figure8:Hades(Gold)vsLethe(Silver) don’tpersiststalegoalsandcaneasilyadapttotheactions + ofotherbots,newlydiscoveredinformation,etc. + AftertheUSQualifiers,weaddedmanysmallimprovements + AdversarialStrategies + andpartialrewritestoLethe.Wealsorevampedourcoordi- + nationsystem,whichpreviouslyusedmarkersforbotcoor- Althoughtheseterritorialstrategiesarenothard-coded,they + dination,sinceitwasbeingdisruptedbyotherteamsplacing emergefromourstatemanagementandscoringsystemand + roadseverywhereandblockingmarkerplacementspots.The areworthhighlighting. + + + + + +===== PAGE 5 ===== + + + + + + CambridgeBattlecodePostmortem + + Ore hopping (Figure 10) is our strategy of using ally- + controlled harvesters as staging points for attack turrets. + By placing turrets along the frontier of our controlled + territory,wecanshootthedefensesaroundnearbyopponent + harvesters.Ourbuilderbotscanthentakeovertheenemy + harvesterbybuildingmoreturretsaroundit. + Orehoppingisaconsistentwayofpushingourfrontline + Before After + forward,sinceslowlytakingoverenemyharvestersbolsters + our economy and increases map control. This often will + Figure11:Seeding. + createturretcombatalongthefrontier. + Theonlydownsideoforehoppingisthatitishighlydepen- Wefrequentlyusegeometrictranslationsofasetoftilesby + dentonorespreadacrossthemap.Iforeclumpsaren’tspread usingtheintegershifts≪1,≫1,≪𝑊,≫𝑊 toshiftatileset + outenough,aturretplacedatanallyorecannotreachan east,west,south,northrespectively.Thesegeometrictrans- + enemyore,anditishardertohopandmeaningfullyexpand lationsareusefulforshiftingbitmaskedfrontiers,computing + ourterritory. neighborhoods/zones,etc. + Overall,map_infomaintainsaroundthirtyderivedmasksto + storeinformationabout: + • Environment/Entitytype + • Facingdirection + • Current/previousvisibility + • Resourceflow + • Dangerzones + • Placementlegality + + Before After Wecancombinethesemaskstoqueryvariousrelevantsub- + setsoftheboard.Forexample,anavoidmaskcanbequickly + Figure10:Orehopping. computedbyORingwalls,enemyturretthreatmasks,and + somebuildings. + Seeding (Figure 11) is a more aggressive way of gaining + ValuesthatcannotbeasinglebitarestoredinPythonlists + territory.Thisinvolvesbuilderbotsinterceptinganenemy + indexedby𝑛:buildingid,entitytype,hitpoints,facing,con- + conveyorlinebyattackingituntilanallied“seed”turretcan + veyoroutputtile,andareverse-adjacencybitmaskofevery + beplacedinthegap.Theanalogyisthatmuchlikeseeding + conveyor-typebuildingthatoutputsonto𝑛.Thatreversead- + a plant, our infrastructure can grow from a single turret + jacencyoftheconveyorgraphallowsforveryfastconveyor + investmentasitdestroysenemytargetsarounditandfrees + flowrelatedcalculations. + upspaceformoreofourturretsandconveyorstobeplaced. + Seedingiseasilycounteredbyenemybuilderbotssimply UpdatingVision + out-healing the attack if nearby, though there are special update_at(pos) calls the cambc controller to update our + casesdescribedlaterwherethiscanbeprevented. knowledgeofaspecifictile.Itiscalledforeverytileinourvi- + sion.Wealsocallitonrelevanttilesafterwebuildsomething + MapInfo + orwhennewtilesenterourvisionaftermoving. + Themapisatmost𝑊×𝐻 ≤50×50tiles,sowecanfitbinary + Sincethismethodiscalledsofrequently,weonlyupdaterel- + information about tiles within a Python big integer of at + evantinfotoavoidwastingruntimeonunneededcontroller + most2500bits.Weencodeinformationabouttile (𝑥,𝑦) in + calls: + bit𝑛 =𝑥 +𝑦𝑊.Byusingbigintegersforvariousproperties + (whetheratileisawall,ifwe’vevisitedatile,etc.),wecan (1) Core-area tiles are handled by separate core logic; + quicklyqueryandcomposespatialinformationaboutthe onlycorehealthisrefreshedeachturnandthefunc- + mapwithC-levelbitwiseoperations. tionreturnsearly. + +===== PAGE 6 ===== + + + + + + Wangetal. + + (2) Symmetry tracking runs once per newly-seen tile. Forsomemasks,wealsoaddourobservedresourcebitmasks + Observingatilethatcontradictsasymmetricalcoun- to the cache key so that the masks are recomputed as re- + terpartclearsthatsymmetryflag. sourcesflow: + cache_key = (_struct_version, + (3) Wallscanneverholdabuildingandneverchange, + _bm_conv_ti, + soiftheobservedtileisawall,thefunctionreturns + _bm_conv_raw_ax, + beforethebuildinglookup. _bm_conv_refined) + (4) Otherwise, we check the building id. If it is un- + DerivedMasks + changedfromlastturn,weonlyrefreshhealthand + stored resources, as well as facing direction for Weuseourbasicmapinfomaskstoderivemorecomplex + gunners(theoneunitthatturns). masksfordecisionmaking. + Enemyturretthreat:Wecomputebothasoftmask(enemy + (5) If the building id changed or was cleared, we up- + sentinelattacktiles)andahardmask(enemygunner/breach + dateallofourbitmaskswiththenewentityinfoand + attacktiles)toguidebuilderdecisions. + bumpthestructuralversioncountersoanycached + calculationsthatrelyonmapinfoaremarkedstale. Wepurposelyneverstepwithinhardmasktilesduetothe + Formarkers,weaddtheirdecodedmessageintothe higherDPSofgunnersandbreaches.Weallowsteppinginto + messagequeue. softmasktilesinourpathfinding,justatahighercost. + Toconstructthesemasks,weprecomputetheattackpatterns + PredictingSymmetry + ofsentinelsandbreachesforeachfacingdirection,thenapply + Sincemapsareguaranteedtobeeitherhorizontally,verti- thesepatternsforturretsfacingeachdirection: + cally,orrotationallysymmetric,welookfortilesthatcontra- + dicteachpossiblesymmetryuntilthereisonlyoneremain- for d in 8 directions: + turrets_d = enemy_turrets & dir_mask[d] + ingcandidate.Wethenusethedeterminedsymmetrytoset + for (dx,dy) in OFFSETS[turret_type][d]: + theenemycorelocationandupdateourknowledgeabout acc |= shift(turrets_d & col_mask, dx + dy*W) + unseentiles: + Sincegunnerscanonlyshootthefirsttileinfrontofthem, + if (hor + ver + rot) == 1 and not solved_sym: + solved_sym = True wechecktheirfacingdirectiononestepatatime,ignoring + # update enemy core location anytilespastablockingwall. + their_core = flip(my_core) + # mirror seen tile envs to unseen half + Thehardandsoftmasksarememoizedon_struct_version + for n in seen_tiles: tominimizerecomputation. + f = flipped_index(n) + if not (seen & bit(f)): Turretfeedpropagation:Weconstructabitmasktomark + copy_env(n → f); seen |= bit(f) whichtilesareproperlyfed.Wepropagatewhetheratileis + struct_version += 1 fedfromharvesters,foundries,andconveyorswithvisible + resourcesonthemupto4stepsdownstream(basedonoutput + Iftherearetwoormorepossiblesymmetries,westillcom- tiles).Thisbitmaskisusedforturretplacementlogictoonly + mittoapredictiononwheretheenemycoreis.Wedefault considertileswithasourceoftitaniumorrefinedaxionite + toassumingrotationalsymmetryunlessit’salreadybeen feedingthem. + eliminated,inwhichcasewepredictthecloserofthehori- + Routetargetsanddeadends:Onepasswalkstheconveyor + zontallyorverticallysymmetricalcorelocations,withthe + graphbackwardfromthecoreareaviathereverseadjacency, + tiebreakrememberedsothepredictiondoesn’toscillatebe- + inlayers,recordingboththereachedsetandatopological-ish + tweenturns.Thispredictedlocationisusedtopredictally + visitorder.Asecondpassthenoverlaysloadedandvisible + vsenemyterritoryandforattackrouting. + state: + Memoization + We memoize complex bitmasks based on a struct version + counter that is incremented on structural changes to the + knownmap(walldiscoveredorbuildingdiscovered/placed + /destroyed). + +===== PAGE 7 ===== + + + + + + CambridgeBattlecodePostmortem + Communication + reaches_core, order = route_reaches_core() + # walk in core-ward order Sincerobotsaresandboxed,allcommunicationmustbere- + for n in order: layedviamarkers.Aunitmayplaceatmostonemarkerper + rl = (run_len[target(n)] + 1 turncarryingau32value. + if loaded(n) else 0) + run_len[n] = rl + PayloadFormat + if rl == 4: # 4 in a row: line jammed + mark 4 tiles from n unroutable Earlier iterations also used markers to communicate task + # a *visible* 4-run jams the whole chain: claimsbetweenbuildersandtouselauncherstospeedup + # flood both up- and downstream through + builderbotmovement,butthosewereretiredinHades.The + # my conveyors + symmetry-broadcastpayloadrepurposedthelowerbitsas + route_targets = core_area | (reaches_core + & ~unroutable a 7-bit corresponding-position code, a 21-bit environment + & ~guard_conveyor) sample,and3symmetrybits. + All field widths and shifts are module constants and the + Ifweobserve4loadedconveyorsinarow,weconsiderthat encode/decodepairisastraightbit-pack: + conveyorlineunroutable,andignoreitinourDijkstra’sfor + conveyorrouteplanning.Thisapproximatesmaxflowsince def encode(target, type, sym, sender): + return ((target & POS_MASK) + wedon’twanttoconnectmoreharvesterstoaroutethatis + | (sym << SYM_SHIFT) + alreadyfull. | (sender << SENDER_SHIFT) + | (type << TYPE_SHIFT)) ^ key + Asasideeffect,thesamepasssetsadead-endmask(targetsof + loadedconveyorswhoseoutputisnotanacceptingbuilding, ThefinalXORwithkeyencryptsourmarkers.Weinitially + plusconveyorspointingintoenemyhardbuildings)anda addedthisasajoke.However,wekeptitintomakeitharder + feeding-enemymask(loadedconveyorswhosetargetisan forteamstodecodemarkersinreplaystofigureouthowour + enemyturret).Theroutestateattemptstoroutedead-ends commssystemworks. + andfeeding-enemyconveyorstoroutabletargets. + SymmetryBroadcasts + We also have a visible-graph check for whether a turret + Althoughtheprimarypurposeofthismarkertypeistoshare + couldpossiblystillbefed.Thischeckisusedbyturretsto + mapsymmetry,wealsouseittosharemapknowledgeabout + decide self-destruction validity: any unseen adjacent tile + distantpartsofthemap. + oroff-screenupstreamfeederkeepstheanswerTrue,soa + turretisonlyallowedtoself-destructwhenthevisiblelocal Wefirstbucketthemapinto5×5cells.Sincemapdimen- + structureprovesitisnotcurrentlysupplied. sionsareatmost50×50,thereareupto100buckets,and + sowecancommunicatewhichbucketwearesharinginfo + Carrying resources: We also maintain a bitmask that + aboutwith7bits.Wethenuse21bitstocommunicatebinary + expands three steps up and downstream from a resource- + informationabout21tileswithinthebucket(alltileswithin + carrying conveyor. We maintain separate bitmasks for squared radius ≤ 5 from the center of the bucket). If the + eachresourcetypesowecancheckifatilecouldcarrya markerisplacedonaneven𝑥 coordinate,wecommunicate + particularresource. + whether or not the tile environment is a wall. Otherwise, + wecommunicatewhetherornotitisatitaniumore.This + Avoidpathing:Wealsomaintainamaskoftilesweshould + resultsineachtypebeingcommunicatedbyroughlyhalfof + avoidwhenpathfinding.Ouruniversalavoidmaskincludes + ourmarkers. + walls,non-walkablebuildings,tilesaroundenemylaunchers, + and tilescovered byenemy turrets. We canalso compute Anyallybotthatreadsthemarkerthenusesthelearnedinfo + avoidmasksthatincludeallyconveyors(sowedon’tchange toupdatethecorresponding_bm_enventryand_bm_seen: + existinginfrastructurewhenplanninganewconveyorroute), + def apply_symmetry_broadcast_map(marker_pos, bucket, bits): + enemyroadsadjacenttoenemybots(sincetheyareeasily + env = ORE_TI if marker_pos.x & 1 else WALL + healable),chokepoint barriers,andnon-landlockedorein for i in set_bits(bits): + theharvestzone. dx,dy = LEARN_MAP_OFFSETS[i] + note_env(bucket.x+dx, bucket.y+dy, env) + WealsohaveanenemyPOVmodetocalculateavoidmasks + with inverted perspective (shows how the enemy would Wallsarethemorevaluableofthetwopayloadtypes,since + perceivetilestoavoid). knowledge of impassable tiles helps our pathfinding and + +===== PAGE 8 ===== + + + + + + Wangetal. + + chokepointdetectionlogic.Titaniumoreobservationsdon’t + # seed the four cost classes into the bucket + affectthesesinceoreispassable,butitdoeshelpformore + frontier[0] = target & no_barr & no_thr + efficientharvestingsinceweknowwhereharvestersshould frontier[bcost] |= target & barr & no_thr + beplacedbeforeexploringthemap. frontier[tcost] |= target & no_barr & thr + frontier[bcost+tcost]|= target & barr & thr + Overall,ourcommunicationsystemissupplementaryonly. i = 0 + Botsdon’tdependoncommunicatedinfo,buttheycanben- while True: + efitfromusingmarkerstolearnmoreofthemap. cur = frontier[i % cyc] & ~visited + visited |= cur + Pathfinding # reached a valid neighbor + if cur & can_move_to: break + WerunabitmaskedversionofDial’salgorithmtofindthe nbr = expand_chebyshev(cur) & ~visited & ~avoid + optimalpathtoagiventarget.Bychangingtheallowedsteps frontier[(i+1) % cyc] |= nbr & nb_nt + frontier[(i+1+bc) % cyc] |= nbr & b_nt + andcost,wecanusethisbothforbuilderbotmovementand + ... (3 more cost classes) + conveyorrouteplanning. + i += 1 + Thesearchisruninreverse,fromtargettilestostart,sothat + pathreconstructionisnotnecessary,asitcansimplyfind When a frontier bit lands on a valid neighbor of the bot + thelowestdistance-to-targettileitcanmoveonto. (thatis,aneighborwecanstepontorightnow),thatdirec- + tionischosen,afterafour-stagetiebreakamongreachable + Dijkstra’sforMovement + neighbors: + Formovement,wedefinefourdifferentedgetypes:anormal + (1) We prefer walkable tiles so we don’t waste our + step(cost1),astepdestroyinganallybarrier(cost15),astep + actioncooldownonbuildingaroad. + intoasoftthreat(cost20),andboth(cost35). + (2) Weskiptilesnexttobordersandfriendlybots + Weconsideranywallsornon-walkablebuildingstobeim- + uptoChebyshev4ifpossible.Avoidingedgesisgood + passable.Wedonotmarkbuilderbotsasimpassabletopre- + formaximizingvision.Avoidingbuilderbotshelps + ventoscillationsinourroutepathasbotsmovearound.In- + preventclumpingorsituationswhereweaccidentally + stead,weonlycheckoccupancyattheendofourDijkstra’s, + stepontoanotherbuilderbot’stargettile. + whenconsideringthemovewewouldmakethisturn. + Wethenuseacirculararrayoffrontierbitmasksasabucket (3) Wepreferwalkinginzigzags.Ifwewalkalongthe + queue,startingfromamaskofourtargettilesanditerating samediagonalfortwosteps,weprefertheopposite + untilwereachatilewecouldmoveontothisturn: diagonalnext.Theintuitionisthatzigzagginggives + usadditionalvisioncoverage. + (4) Wepreferdiagonalsovercardinals,againtomax- + imizevisioncoverage. + Wealsobreakallybarriersandplaceroadsasneededwhen + movingontoatile.Wewilltrytoreplaceanybarrierswe + brokeatthestartofeachturn. + Dijkstra’sforRouting + Forrouting,wedefinetwodifferentedgetypes:aconveyor + step(cost1)andabridgestep(cost6). + Whenbuildingourtargettilemask,weallowforendingon + Figure12:Dijkstrafrontierexpansionoverfiveitera- existingconveyors,ratherthanthecoreitself.However,we + tions. add a cost of 4 to loaded conveyor tiles, to bias our route + plannertoformnewconveyorlinesunlesstheexistingline + issignificantlybetter. + SincewecannotstoreparentpointerswhenrunningDijk- + stra’s,westillneedtocalculatethedesiredoutputofthefirst + conveyororbridgeweshouldbuild.Wedothisbytesting + +===== PAGE 9 ===== + + + + + + CambridgeBattlecodePostmortem + + eachpossibleoutputtoseeifitexistsinthematchingprior + my_front, other_front = my, others + costlayertoourcurrenttile. + while remaining_claims and (my_front or other_front): + my_front = expand(my_front) & passable & ~claimed + Weusedifferenttargettilesetswhenattackroutingtothe + claimed |= my_front; remaining &= ~my_front + ringaroundtheenemycoreorroutingtoafoundry. + other_front = expand(other_front) & passable & ~claimed + claimed |= other_front; remaining &= ~other_front + ConveyorCost return my_claimed & candidates + Wecalculatethetitaniumcostoftheoptimalconveyorroute + todecidewhetherweshouldstartbuildingitnow.Thisisto StuckDetection + preventbuildersfromstartingroutesfromveryfarawayif + Ifmorethan2+(idmod8) turnspasswithoutmovement + wearelowontitanium. + progress,builderbotswillmovetoarandomadjacenttileto + getunstuck.The(idmod8)termaddssomevariancebased + Wecalculatethecostusingthisformula: + onbuilderbotidsothatthisbehaviordoesn’tinadvertently + syncupacrossbuilderbots. + cost(𝑑) = (cid:0) 3𝑑𝑠+0.015𝑑(𝑑−1) (cid:1) ·0.65, + TheCore + forpathlength𝑑atcurrentscale𝑠.Thisisanarithmeticseries Thecoreismainlyresponsibleforspawningbuilderbots.It + formulathatletsuscalculatethebase3𝑑𝑠costofbuildingthe willalsoconvertaxionitetotitaniumasneeded. + conveyors,withanadditional0.015𝑑(𝑑−1)termtoaccount + SpawningBots + fortheincreasedcostofeachconveyorasscalingincreases. + Thespawnpolicyhasmultiplechecks: + The0.65constantisanarbitrarydiscountweapplytothe + total cost. We don’t want builders to be too conservative if not has_ally and closest_enemy: + aboutstartingroutesbacktothecore,sincewegaintitanium spawn_on_core_tile_nearest(closest_enemy) + else: + overtime(frompassiveincomeeveryfourturns,orother + baseline = 400 if allies ≥ 12 else 200 + existingroutes). + if titanium > baseline + scaling: + spawn_toward_plan() or spawn_toward_center() + BFSforClosestTarget + We used Dijkstra’s for navigation and routing since we Thefirstfourbuildersarespawnedindifferentdirectionsfor + wantedtoaccountformultipleedgeweights.However,we betterexplorationcoverageatthestartofthegame.After + useamuchfasterunweightedBFSwhenwewanttofind that,botsarespawnedtowardavisibleenemyifthereisno + theclosesttargetforactionplanning. allynearbyasadefensivemechanism.Otherwise,wewait + untilwehaveenoughtitaniumandthenspawnalliestoward + This frontier BFS mimics builder bot movement by only thecenter. + expandingpassabletiles,minustileswewanttoavoid(such + as tiles adjacent to enemy launchers). This is used when InitialSpawnPlan + findingtheclosestallybuildingwecouldheal,theclosest + Thecoreorchestratesinitialexplorationbyspawningthe + enemywecouldchase,etc. + firstfourbuildersonthetilefacingthedirectiontheyshould + The closest function also exposes a side flag that lets us explore. + optionallyruntheBFSfromtheenemy’spointofview.This + Tofindthebestdirections,wefirstcheckwhichoftheeight + is used to determine how far the enemy is from a target, + possibledirectionsshouldbeconsidered.Foreachdirection, + whichhelpsusreasonaboutwhetherornotwecouldreach + wechecktheraypointinginthatdirection.Iftheray’send- + thattargetfirst. + point is out of vision, or there is titanium along the ray + path,weconsiderthedirectionvalid,sincethereisvaluein + FloodfillforVoronoiPartition + sendingabuilderbotinthatdirection. + Eachbuilderscoresitsstatesindependently,soanexplicit + Fromthesetofvaliddirections,wepickthefourthatmaxi- + mechanismisneededtostopbuildersfromcompetingover + mizetheproductofpairwiseangularseparations,tospread + tasks.Weuseaclaim-partitionpassthatperformscompeting + thedirectionsout: + simultaneousChebyshevflood-fillsfromthebotversusall + otherfriendlybotsandreturnsonlythecandidatesthebot + reachesfirst: + +===== PAGE 10 ===== + + + + + + Wangetal. + PersistentBuilderState + best = argmax over 4-subsets S of + prod(angular_dist(a,b) for a,b in pairs(S)) Thereareafewexceptionstoourmemorylessarchitecture. + WemaintainTTLcachesforeachstatesothatbuilderswill + notreconsidertargettilesthatfailedrecently.Wealsochange + We tiebreak by preferring diagonals if there are multiple + thebehaviorofthefirstfewbuilders: + setsofdirectionswiththesamepairwiseangularseparation, + sincediagonalsgiveusbettervisioncoverage. + The very first builder spawned will restrict its targets to + squareddistance≤100ofthecore,soitstaysnearthecore + Oncewehavethefourinitialdirections,wespawnbotsin + asaguard. + orderoftheirdirection’sproximitytothemapcenter,since + thatismorelikelytobeacontestedregionandwewantto Thefirstfourbuilderswillcheckthetiletheyspawnonto + gettherebeforetheenemydoes. decidetheirinitialexploredirection(seeInitialExploration). + Theywillclearthistargetuponarrivaloraftera30-round + ConvertingAxionite + timeout. + Weusethefollowinglogicforaxioniteconversion: + States + if round < 1500 and titanium < 4 * harvester_cost: + convert(clamp((3*harvester_cost - titanium)//4)) Builderbotshavesevenpossiblestatestheycanrun.Every + stateexposesascore()functionthatusesglobalmapinfo + tocalculatestatepriorityandreturnavaluestrictlybelowa + Thisallowsustoconvertaxionitetotitaniumwhenweare + fixedceiling(showninTable1).Wethenrunthelogicfor + runninglow,upuntilturn1500,sinceaccumulatingaxionite + thehighestscoringstate. + forthetiebreakerisimportantinlong-runninggamesthat + mightreachround2000. + Table1:Builderstatesbyscoreceiling. + BuilderBots + State Max Role + Builderbotsdothefollowingeachturn: + attack 9 buildsentinel/gunner + (1) Updatemapinfousingthecambccontrollerand + heal 8 repair/chaseinvaders + anylearnedmapinfofromallymarkers + route 7.75 routeresourcesback + (2) Rebuildbrokenbarriersifwedestroyedanypre- secure 7.5 surroundanoretile + viouslyformovement harvest 4 buildaharvester + disrupt 2 barrierenemy-sideore + (3) Scorestatesandrunthebeststate + explore 1 exploreunseentiles + (4) Placealauncherorbarrierifwedetectanearby + chokepoint We calculate state scores in descending order of the max + possiblescoreforthatstate.Thisavoidshavingtocompute + (5) Healanadjacentbuildingandfallbacktohealing + score()onlower-ceilingstatesthatcannotwinoncewe’ve + ourselvesotherwise + foundaprovablybetterscore: + (6) Destroyenemyfeedersifadjacent + for state in states_desc: # sorted by MAX_SCORE + (7) Tryspammingroadsifemptytileadjacent if best_score ≥ state.MAX_SCORE: + break + (8) Placemarkertobroadcastsymmetryandmapinfo s = state.score() + if s > best_score: + Anenemyfeederisanallyconveyor,bridge,orharvester best_score, best = s, state + thatoutputsontoanenemyturret.Forharvesters,wealso + checkthattherearenoallyturretsbeforedestroyingit. StatePattern + Whenspammingroads,weprioritizeplacingroadsadjacent Eachstatefollowsthesamepattern.Thescore()function + toallyconveyors.Wealsoplaceroadsaroundenemybots derivescandidatetilesforthestate(suchasbuildingswecan + orbuildingsifanenemyisnearby.Roadspamhelpsuswith healforhealstate).Weintersectthecandidatetilesetwitha + mapcontrolsinceenemieshavetodestroytheroadsfirst Voronoiclaimpartitiontoavoidmultiplebuilderscontesting + beforetheyplacetheirownbuildings.Wedonotspamroads thesametargettile.Wethencachetheresultforrun()to + infrontofourgunnerstokeeptheirfiringpathunblocked. useifthestateendsupbeingchosen. + +===== PAGE 11 ===== + + + + + + CambridgeBattlecodePostmortem + Attack(ceiling9) Gunner scoring follows the same idea along a directional + + Attackisourhighestprioritystatewhereweplaceturretsto rayofuptothreesteps.Thescoreisdiscountedby0.9 𝑘 at + targetenemybuildings.Wepreferplacingturretsonempty + step𝑘,therayisblockedbywallsandourownnon-road + tilesorlessimportantinfrastructure(roads,guardconvey- buildings,andthecoreissingle-countedatitsclosesthit.This + ors,etc).Wewillfallbacktoplacingonnon-guardconvey- stepdiscountprioritizesplacinggunnersdirectlyadjacent + ors/bridgesorenemyinfrastructurewecoulddamage,but totargets,sincetheycan’tshootovertileslikesentinelscan. + atahigherthreshold. Wealsoaddarotationbonusequaltothesumoveralleight + facings shifted right by 3, to still favor targets we might + rotatetowardlater. + Weapplyapenaltytoplacementpositionsunderthreatby + enemyturrets.Wealsoapplyapenaltytoplacementsthat + woulddestroyanallynon-guardconveyororbridge.Allof + this is memoized per round keyed by the structural state, + enemypresence,andplacementmasks. + FilteringTiles + The candidate pass returns the legal placement mask per + facingdirection.Weseedfromtilesfedbytitaniumoraxion- + Figure13:Attack. + ite,plustheManhattandilationoftitaniumharvestersand + foundriesnotalreadyusedbyoneofoursentinels.Sentinels + Bit-SlicedScorePlanes consumemoreammothanaharvestercanprovide,sowe + avoidbuildingmultiplearoundthesameharvester. + Foreachfacingandturrettype,wemaintainascoreplane + stack:alistof9bitmaskswherebit𝑖oftheintegerscoreattile + Wethenintersectwithseenandbuildabletiles,andremove: + 𝑛is(planes[i] » n) & 1.Thisletsusdobulkscoreupdates + andqueriesoverthewholeboardwithbitwiseparallelism, • Tileswithinatwo-stepenemy-botBFSthroughtheirpass- + ratherthaniteratingoveranarray. ablegraph.Wetreatourlauncher3x3zonesasimpassable + forenemies,sinceanenemyenteringoneisflungback. + Toaddaconstant𝑐 tothescoreofeverytileinamask𝑀, + foreachsetbitof𝑐 weXORthecorrespondingplanewith • Our own turret feed chains, so a new placement never + 𝑀 (togglingthatdigitateverytilein𝑀 simultaneously)and cutsoffanexistingturret. + rippleanyAND-carriesupthroughhigherplanes: + • Per-directionloaderblockers,sincefeederscannotloada + The key property is that the cost of calculating scores is turretpointingintothem. + independent of the number of tiles scored. The per-digit + Sentinelsadditionallyexcludetileswithinenemygunneror + loopisboundedbythefixedplanecount,andeachiteration + breachrange,sincetheyshootslowlyandareunlikelyto + isonebig-integerAND/XORoverthewholemap.Thisis + winthetrade.Gunnersarepermittedonthosetilestoenable + whatmakesexhaustiveplacementevaluationfitwithinour + harvestercombat. + per-turnbudget. + Weonlyconsiderplacementtileswhosescorespassathresh- + ScoringTiles + old.Sentinelsandgunnershavedifferentthresholds,since + Wegroupenemybuildingtypesbyequalscoresoonemasked theyhaveslightlydifferentscoringsystems.Thethreshold + addhandlesawholescoreclass.Foreachoftheeightfacings, isalsohigherforenemyinfrastructurewewouldhaveto + thesentinelpassshiftseachenemy-typemaskbythereverse destroyfirstbeforeplacing,orallyinfrastructurewedon’t + ofeverysentinelattackoffsetunderthatfacingandANDs wanttodestroy(non-guardconveyorsandbridges). + withthevalid-placementmask.Whereverabitsurvivesisa + tilefromwhichasentinelfacing𝑑 wouldhitthatenemy,so PlacingTurrets + weaddthetype’sscoreintotheplanesthere. + Ifwecanplaceaturretonapreferredtilethisturn,either + Theadvantageofthisapproachisthatweneveriterateover fromourcurrenttileoranadjacenttile,wepickthehighest + individual placement tiles. The total time scales with the scoringsuchplacement.Thisistofavorturretswecanplace + numberofhitoffsets,facings,buildingtypes,andscorebits, thisroundoverpotentiallybetterplacementswewouldonly + ratherthanwiththeboardsize. reachinalaterround. + +===== PAGE 12 ===== + + + + + + Wangetal. + + # Add the constant c (c = sum of 2^b for b in `bits`) to the stored score of EVERY tile in `mask`, + # in O(NUM_PLANES) whole-board big-integer ops -- cost independent of how many tiles are in `mask`. + def add_bits_to_planes(planes, bits, mask): + for i in bits: # for each set binary digit of the constant c + carry = planes[i] & mask # tiles where digit i overflows + planes[i] ^= mask # add c's i-th digit to all those tiles at once + j = i + 1 + while carry and j < NUM_PLANES: # ripple the carry up through higher planes + new_carry = planes[j] & carry + planes[j] ^= carry + carry = new_carry; j += 1 + # Bitmask of tiles in `cands` whose stored score ≥ thr, computed in one MSB-first pass. + def ge_threshold_mask(planes, thr, cands): + eq, gt = cands, 0 + for i in range(NUM_PLANES - 1, -1, -1): + p = planes[i] + if (thr >> i) & 1: eq &= p # thr digit set: tile must match to stay "equal so far" + else: gt |= eq & p; eq &= ~p # thr digit clear: a set score digit => strictly greater + return gt | eq + Figure14:Thetwomainbit-slicedprimitives.add_bits_to_planesaddsascoreconstanttoanarbitrarysetoftiles + in𝑂(planes)big-integeroperationsregardlessofhowmanytilesareintheset.ge_threshold_maskthresholdsthe + wholeboardagainstascalarinonepassfromthemostsignificantbitdown. + require a delta of at least 2; launchers require at least 5, + reflectingtheirrelativecost. + + + + + + + + + Figure16:Thislauncherpreventstheenemyfromad- + vancing,asthelauncher’s3x3andthebarrierforma + Figure 15: Valid turret locations. Gunner in red and wall + sentinelinblue. + Wenormallyavoiddamagingenemyconveyortargetsifa + nearby enemy could heal them. However, we have three + Otherwise, we pick the closest preferred or fallback tile. + exceptions: + AmongcandidateswithintwoBFSsteps,wemaximize + • Welocallyoutnumberthethreat(atleast2allybuilder + enemyBFS adj (cand)−myBFS(cand), bots within squared distance 25 versus a single enemy + withintwoBFSsteps). + treating our launcher zones and barriers as enemy- + impassable. • An allied sentinel is in sight, so we damage more than + theycanhealanyway. + Ifthetargetisanenemyconveyorthatavisibleenemycould + heal,wescanadjacentbuildabletilesforalauncherorbarrier • The target’s 3x3 is fully sealed by walls, non-walkable + placementthatstrictlyincreasestheenemy’sBFSdistanceto buildings,orourlauncher3x3s,soenemiescannotreach + theconveyorwithoutworseningourownapproach.Barriers thetargettile. + + + +===== PAGE 13 ===== + + + + + + CambridgeBattlecodePostmortem + Heal(ceiling8) This most commonly happens when an enemy places a + + Thehealstatedefendsagainstopponentattacksintwoways. launcher to keep our bot away from its attack target. In + First,wehealdamagedallybuildingsadjacenttous.Second, thiscase,weplacealauncherinresponsetokeeptheirbot + wechaseenemybotsthatwanderintoourconveyorzone away.Wewillplaceagunnerinsteadifthetargettileisone + beforetheycanattackourinfrastructure. ofourguardconveyors. + Score returns 7 if any friendly building has more than 2 Route(ceiling7.75) + damage, or if a friendly sentinel and enemy sentinel are + Theroutestatebuildsconveyorlinesfromharvestersbackto + withineachother’sattackranges.Wetreatthesesentinels + thecore.Ittriestoroutefromdead-endconveyors,orphan + asverydamagedsowestaynearitastheytradedamage + harvesters,andorphanfoundries.Astructureisanorphan + withtheenemysentinel.Scorealsoreturns7ifachasetarget + ifithasn’tbeenroutedbackyetandhasatleastonecardinal + existswithintheconveyorzone,definedaswithin8pathing + neighborwecanbuildon. + stepsofaconveyor.Wescore2.5ifachasetargetexistsbut + isfarther. Scorereturns5normally,but7.75(aboveheal’s7)whenany + claimisimportant: + important = expand_chebyshev(enemy_bots, 5) + & ~(my_harvesters | my_foundries) + | feeding_enemy + return 7.75 if important & claims else (5 if claims else 0) + Wewillplacearmouredconveyorsinsteadofnormalcon- + veyorsifwehaveaxioniteavailableandatleast5×thecost + intitanium. + Ifwedon’thaveenoughtocontinuerouting,butthereisan + enemynearby,weplaceabarrieronthedead-end’soutput, + Figure17:Heal. + orthedead-endconveyoritselfifneeded,topreventenemies + fromtakingadvantageofit. + At the end of each turn, we scan adjacent tiles and heal + theonemaximizingdamagetimesafixedtypepriority.We Whenplacingconveyors,weupgradetoarmoredconveyors + consider all very damaged buildings before less damaged whenwehaveatleast5xtheircostintitaniumandwehave + ones,andconsidersixprioritytiers: axioniteavailable. + (1) Core + AttackRoute(override) + (2) Turrets WeaddedattackroutinginKhaosforthefinaltournament. + Instead of routing resources back to our core, the builder + (3) Armoredconveyors,harvesters,andfoundries + extendstheconveyornetworktowardtheopponenttodis- + (4) Regularconveyors rupttheireconomyandinfrastructure.Theoverridetriggers + whenourBFSdistancetotheenemycoreisatmost1.5×our + (5) Barriers,bridges,andsplitters + BFSdistancetoourowncore. + (6) Roads + Intesting,wefoundthataggressiveattackroutingwasusu- + Forchasetargetselection,weremoveenemiesunderattack + allyanetwin.Evenwhentherouteneverreachedtheenemy + byaturretandenemiesthatanotherfriendlybotisstrictly + core,thedamageitcausedtoenemyinfrastructureandarea + closerto.Wethenprefertheclosestsurvivingtarget. + controlwasusuallyenoughtojustifythelossofresources. + Whenchasefailsonpathing,thelauncherfallbackfiresasa + Ifwerealizewecanextendtherouteintoagunner,wedo + counter-launcherresponse: + thatinstead.Weonlyplaceasentinelwhenagunnerwould + for enemy on a damaged friendly conv (unclaimed): besignificantlyworse,suchaswhenweneedtoshootenemy + # T1: placeable cell adjacent to enemy buildingsfrombehindawall. + T1 = adj(enemy) of those four types + # T0: a stand-tile within 8 BFS steps of T1 InFigure19,ratherthansimplyplacingasentineltoshoot + T0 = adj(T1) reachable within 8 thecore,intwooutofthethreeroutes,thebotrealizedthat + pick (enemy, T1) minimizing my dist to T0 + the route could be extended slightly to support a gunner. + +===== PAGE 14 ===== + + + + + + Wangetal. + + Thesecurestatescores7.5ifaclaimsitsonanalready-built + harvester or foundry, since we don’t want to leave these + buildings exposed to enemy turret placements. The state + scores3ifthereisanore,butnoharvesteryet,sincewestill + wanttosecureitbeforeweharvest. + Wegenerallysecureanoreusingguardconveyors.These + inward-facing conveyors are not valid outputs of the ore + tile,sotheyneveractuallycarryresources.However,they + preventtheenemyfromplacingaturretadjacenttotheore, + and they have significantly more health than a road. We + placetheseinsteadofbarrierstoimprovethetraversability + ofourinfrastructureforbuilderbots. + Figure18:AttackRoute. Whilesecuring,wealsocomputetheoptimalrouteandplace + the first conveyor facing outward, instead ofguardingby + facinginward,toslightlyspeedupoureconomy. + + The route-target pass detects this pattern via a guard- + conveyormasksothatconveyorswhosetargetisopenore + areexcludedfromtheroutinggraph. + Topreventoscillationsthatwouldotherwiseoccurasthe + builderbotmovestoplaceguardconveyors,whenitisclose + enoughtoseveralunsecuredores,itwillpickthemostse- + curedore(themostsidescovered)withinChebyshevdistance + 2,ratherthanjusttheclosestone. + + Harvest(ceiling4) + Figure19:AttackRoutewithGunner. + Harvestplacesaharvesteronanoretile.Wepickfrompos- + sible ore tiles that don’t already have a harvester and are + Inthethirdroute,enemyroadsblockedtheextension,soit secured. + optedforasentinelinstead. + Thepossible-oremaskincludestitaniumore,plusaxionite + Secure(ceiling7.5) oreafterround750ifwe’vealreadyharvestedtitanium.It + excludeslandlockedore(orewhosefourcardinalneighbors + Secureandharvestgenerallyexecuteinsequence.Secure + are all ore or unseen, since these are difficult to reach by + firstplacesguardconveyorsonthefourcardinalneighborsof + conveyor).Wealsoavoidexistingallyinfrastructure(con- + anoretile.Oncethetileissecured,harvestplacesaharvester + veyors, bridges, etc.), enemy blocking buildings, and tiles + onit.Thissequencepreventsenemiesfromplacingturrets + underenemyturretthreat. + totakeadvantageofharvesterswithexposedsides. + + + + Figure20:Secure. Figure21:Harvest. + + + + + + + +===== PAGE 15 ===== + + + + + + CambridgeBattlecodePostmortem + + We compute secured() for the entire map using bitmask + operations.Atileissecuredifeverycardinalsideisoffthe + map,awall,oroneofournon-roadnon-markerbuildings. + Ifbuildingtheconveyorlinetothetargetistooexpensive, + we add the tile to a temporary failure cache so we don’t + consideritfor100rounds. + Disrupt(ceiling2) + + Disruptplacesbarriersonoretilesoutsideourharvestzone, + toblocktheenemyfromharvestingthem.Weskiporesunder + enemyturretorlaunchercoverage.Thisstateonlyrunswhen + Figure23:InitialExplorationTargets. + wehavesignificantresources(atleastfiveharvestersworth + oftitanium),andactsasacheapwayofdenyingtheenemy’s + economy. + FrontierSampling. Ifnotfollowingtheinitialexploreplan, + Ifthetargettilealreadyhasanenemyroad,wefireonitfirst. + theexplore-targetgeneratorfloodfillsfromthebot’scurrent + Ifit’safriendlyroadormarker,wedestroyitandthenplace + position and other known friendly bot locations. It then + abarrier. + samplesarandomtilefromafrontierringneartheedgeof + theexploredareaoncethefloodterminates: + seeds = me | friendly_bots + frontier = seeds; ring_buffer = deque(maxlen=6) + while frontier and c < 100: + frontier = expand_chebyshev(frontier) & passable + & ~visited + visited |= frontier; ring_buffer.append(frontier); c+=1 + target = random_bit(ring_buffer[0]) # the ring at c-5 + Figure22:Disrupt. Seedingfromteammatesbiaseseachbot’sfrontierawayfrom + whereothersalreadyare,allowingforbetterareacoverage. + Explore(ceiling1) + Turrets + If we have nothing better to do, our builder bots wander + Sentinels and gunners share the same priority system for + towardunexploredterritory. + scoringtiles.Thegoalistoprioritizedisruptingtheenemy + Ourfirstfewbotshavetheirowninitialexplorelogic.Oth- infrastructureasmuchaspossible,byconsideringinorder: + erwise,wewandertowardarandomtilenearthefrontierof + (1) Enemyfoundriesthatfeedatleastoneenemyturret + ourexploredregion. + andnoneofmine. + InitialExploration + (2) Enemy harvesters that feed more than one enemy + Forthefirstfourbuilders,thecoreusesthetileitspawns turretandnoneofmine. + eachbuilderontocommunicatewhichdirectionthebuilder + (3) Enemyturretsthatcanhitoneofmyturrets,plusthe + shouldexplore.Thisletsthecoreorchestrateinitialexplo- + enemyconveyorsthatfeedthemandtheharvesters + rationforbettercoverage. + whosechainreachesthem. + Onthenewbuilder’sfirstturn,itreadsitsownpositionrel- + (4) As(3)butforanyenemyturret,droppingthe“threat- + ativetothecore,recoverstheintendedfacingbycomparing + ensmine”guard. + itstilewiththecore,andreproducesthesameray-endpoint + computationthecoreruns,cappedat12Chebyshevstepsto + (5) Enemybuilderbots + preventthebotfromwanderingofftoofar. + (6) Enemyroads,conveyors,splitters,bridges,orbarriers + Thebotfollowsthattargetuntilitiswithinsquareddistance + cardinallyadjacenttoanyharvesteroneitherteam. + 18or30roundshaveelapsed,afterwhichtheslotiscleared + anddefaultexploretakesover. (7) Anyenemyentity,otherthanroadsormarkers + +===== PAGE 16 ===== + + + + + + Wangetal. + + Ifanytargetisadjacenttotwoormoreenemybuilderbots, + weconsideritafteralloftheprioritiesabove,regardlessof + itsnormalpriority. + Withineachpriority,wetiebreakbyconsidering: + + (1) Thehighesthealthentitywecouldone-shot + + (2) The entity furthest from enemy builder bots (so it + takesthemlongertomovetowardandhealit) + + (3) The highest weight entity (varies between gunner + andsentinel,butgenerallyprefersshootingenemy + Figure24:Asentinelone-shotonanenemyharvester. + turrets,theninfrastructure) + (4) Thelowesthealthentity + Gunner + Weignoreconveyors,harvesters,orfoundriesthatfeedour + Gunnerscanonlyshootintheirfacingdirection,sowefire + ownturrets,sowedon’tshootenemybuildingsthatweare + if there is an enemy target in front of us. Otherwise, we + benefitingfrom.Wealsoignorebuildingsifanallybuilder + considerrotatingifweseeanenemybuilding,oranenemy + botisonthesametile,sincethebuilderbotwouldbehit + botstandingonadamagedallyconveyorthatdoesn’thavea + instead. + nearbyallybuilderbottohealit.Thisistopreventenemies + fromcontinuingtofreelyattacktheconveyor. + Sentinel + Rotation requires at least 60 global titanium, so a gunner + Oursentinelscoordinateshotsbywaitingtoshootifthey + doesn’tburnoureconomybyrotatingrepeatedly. + noticeanallysentinelrecentlyshotatargetinvision.This + appliesifweseeatarget’shealthdropandseeanotherally + Launcher + sentinelwiththattargetalsoinvision.Inthiscase,wewait + untilthehealthdropsagainsowecanshootonthesame Launchersthrowenemybots.Alauncherclassifiesitselfonce + turnanddestroyitbeforeenemybuilderbotscanheal. onplacement.Achokepointlauncher(onewithnoenemy + conveyorwithinits3×3)firsttriestoflingtheenemyback + if locked and can_one_shot(locked): + fire(locked) toatiletheyrecentlyoccupied,usingthetrackedper-bot + elif locked: positionhistory.Thisforcesthemtoretracetheirpath. + wait # don't waste 18 dmg + elif select_best gives a one-shot: Ifthatfails,orforoffensiveanddefensivelaunchers,weuse + fire it # one-shots ignore lock + theregion-minimizingthrowheuristic.Foreverylegaldesti- + else: + nation,weChebyshev-floodtheregionthebotcouldnavigate + fire select_best pick + fromthere,usingenemy-POVpassabilityandtreatingun- + seentilesasimpassable.Wethenchoosethedestinationthat + We only coordinate one-taps this way for sentinels with yieldsthesmallestreachableregion.Wetiebreakbygreatest + a higher id than the other ally sentinel in vision. This is wall-onlyBFSdistancefromtheseedsetconsistingofevery + becauseentitiesactinincreasingidorder,sothehigher-id conveyorplusthelauncheritself: + sentinelwillalwaysbeabletoreacttothelower-idsentinel’s + for dest in legal_launch_tiles: + shotwithinthesameturn,butnotviceversa. + region = flood_chebyshev(dest, enemy_passable + & seen) + Oursentinelswillconsiderself-destructingiftheyarenot size[dest] = popcount(region) + throw to argmin (size[dest], -wall_bfs_dist(dest)) + adjacenttoaharvesterandthereisnoenemybuilderbot + nearby. Theywill self-destructafter16 turnsifthey have + noammo,orafteronly4turnsiftheyalsohavenopossible Theeffectistoeitherkeeptheenemyoutofourterritory,or + upstreamfeeder. keepthemawayfromatilewearetryingtodisrupt. + +===== PAGE 17 ===== + + + + + + CambridgeBattlecodePostmortem + ChokepointDetection Theoldversionreliedonpolygonunion,difference,intersec- + + Wealsohaveasignificantamountoflogicgearedaround tion,andboundaryextractionfromexternallibraries.The + chokepoint detection and subsequent launcher or barrier runtimeversioninsteaddoeseachstepdirectlyonthemask: + placement,andsowilldedicatethenextfewpagestothat. scanlinepolygonfillfortheanalysisregion,blockfillingfor + Chokepointdetectionstartsrunningoncewehavelearned walltiles,flood-fillcomponentfilteringforsmalldiscarded + enoughofthemap,andthencontinuesrunningintheback- obstacles,andbooleansubtractionforfreespace.Sincethe + groundthroughanincrementalsave-statefortherestofthe + mapsareatmost50×50,evenamoderatelyupsampledmask + game. issmallenoughforpurePython. + Thegoalistoproduceasmallsetoftacticallyusefultiles + Once free space is known, the boundary is extracted by + whereasinglebarrierorlaunchercanchangetheconnec- + scanningforfree/non-freetransitions.Consecutivecollinear + tivity of the map. In practice, these are narrow corridors + boundary pixels are merged into longer axis-aligned seg- + between larger regions, especially corridors that separate + ments,whichkeepsthelatersamplingstepmuchsmaller + ourcontrolledterritoryfromtheopponent’sside.Duetofea- + thantreatingeveryrasteredgeindependently: + turessuchasbarrierwalkthroughandalaunchereffectively + blockinga3×3area,theseregionsbecomechokepointsthat segments = boundary_segments_from_mask(free_mask, scale) + onlyourbotscanpass. + # Internally: + # - scan top/bottom exposed edges row-wise + # - scan left/right exposed edges column-wise + # - merge adjacent horizontal and vertical runs + Thosemergedboundarysegmentsbecometheinputsites + forasweep-lineVoronoiimplementation.Infloatmode,the + runtimechokepointersetsthenumericbackendtoordinary + Figure25:Chokepointbarrierwalkthrough. PythonfloatsratherthanDecimal.Thislosesarbitrarypre- + cision,buttheinputsarealreadyquantizedtomap/sub-tile + SimplifiedGeometryCore coordinates,andthespeedupislargeenoughtomatterfor + all-mapiteration. + ThestandalonechokepointlogicneededtorunwithoutSciPy, + Shapely,NumPy,orcompiledgeometrylibrariessincethey set_foronoi_numeric_mode("float") + werebannedforthiscompetition.Hence,acustomgeometry + points = collect_boundary_samples(sample_spacing) + engine was needed. The base library was vendored from + vor = Voronoi(Polygon(analysis_polygon)) + theForonoiPythonlibrary,butcompletelyrestructuredand + vor.create_diagram(points=points) + optimizedforthepurposesofthiscompetition. + for edge in vor.edges: + Thecoreideawastoreplaceheavyweightpolygonopera- + p1 = edge.origin() + tionswithasmallraster-backedgeometrylayer.Themapis p2 = edge.twin.origin() + firstliftedintoahigher-resolutionbooleanmask,whereeach if segment_is_inside_free_space(p1, p2): + gametilebecomesasmallscale×scaleblock.Thescaleis add_raw_edge(p1, p2) + derivedfromtherequestedsamplespacing,givingenough + sub-tileprecisionforgeometrywhilekeepingthewholemap + tiny: Theruntimealsoreplacesgeometriccontainmentqueries + with mask containment. A candidate Voronoi edge is ac- + scale = raster_scale_from_spacing(sample_spacing) + cepted only if several sampled points along the segment + remaininsidethefree-spacemask: + analysis_mask = build_analysis_mask(rows, cols, scale, + analysis_polygon) + def segment_is_inside_free_space(a, b): + obstacle_mask = build_obstacle_mask(walls, rows, cols, + steps = max(5, ceil(length(a,b) * scale * 2)) + scale, analysis_mask) + for k in range(1, steps): + p = lerp(a, b, k / steps) + kept_obstacles, discarded = split_obstacle_mask_by_area( + if not point_is_inside_free_space(p): + obstacle_mask, min_area * scale * scale) + return False + return True + free_mask = build_free_mask(analysis_mask, kept_obstacles) + +===== PAGE 18 ===== + + + + + + Wangetal. + + Finally,eachVoronoivertexneedsaclearanceradius,which changesenoughtoinvalidatethem.Thisiswhyourbuilder + isthedistancetothenearestwall/free-spaceboundary.In- botstimeouteveryturn. + steadofaskingageometrylibraryfordistance-to-boundary, + theruntimestoresthemergedboundarysegmentsintwoflat MapReduction + lists:verticalsegmentsandhorizontalsegments.Radiusthen + Thefirststepistodecidewhichpartofthemapweactu- + becomesadirectminimumoversquaredpoint-to-segment + allyneedtoanalyze.Becauseeverymapissymmetric,once + distances: + symmetryissolvedweonlyanalyzeourhalfofthemapand + best = INF mirror the result. This cuts the work by roughly a factor + oftwoandalsopreventsduplicatechokepointsfrombeing + for x0, y1, y2 in vertical_boundaries: reportedonbothsidesofthesamesymmetriccorridor. + dx = x - x0 + dy = clamp_distance_to_interval(y, y1, y2) if symmetry == VERTICAL: + best = min(best, dx*dx + dy*dy) analysis_poly = half_map_containing(my_core) + elif symmetry == HORIZONTAL: + for y0, x1, x2 in horizontal_boundaries: analysis_poly = half_map_containing(my_core) + dy = y - y0 elif symmetry == ROTATIONAL: + dx = clamp_distance_to_interval(x, x1, x2) analysis_poly = clipped_by_perpendicular_bisector( + best = min(best, dx*dx + dy*dy) my_core, enemy_core) + else: + radius = sqrt(best) analysis_poly = full_map + Thisfloatruntimeisintentionallylessgeneralthanafullge- + Theinputtothegeometricpipelineisthesetofknownwalls + ometrykernel.Itonlyneedsaxis-alignedtilemaps,clipped + intersectedwiththisanalysispolygon.Unknowntilesare + analysispolygons,boundarysampling,free-spacecontain- + treatedconservatively:wedonotwanttoplanapermanent + ment,andclearanceradii.Byspecializingtoexactlythose + blockeraroundacorridorthatmaylaterturnoutnottoexist. + operations, the chokepointer stays fully pure Python and + Symmetry broadcasts help here because they let us infer + remainsfastenoughtorun. + far-awaywallsbeforephysicallyexploringthem. + OnlineSave-Stating + VoronoiSkeleton + Thisseparationwasimportant.Thegeometrycodeisrela- + ThemainversionofthedetectorusesaBroodwarTerrain + tivelycomplex,whereasthein-gameusagehastobesimple + Analyzer(BWTA)-adjacentmedial-axisapproximation.We + androbust.Thebuildershouldnotcarewhetherachoke- + samplepointsalongtheboundaryofthefreespace:outer + pointcamefromaVoronoiedge,aprunedgraphchain,or + mapboundary,wallperimeters,andanyobstacleholes.Then + amergedregionboundary.Itonlyneedstoknowwhether + werunaVoronoidiagramoverthosesamples.Voronoiedges + standingnearatileandspendinganactionthereislikelyto + thatlieinsidefreespaceapproximatethemedialaxisofthe + improveourposition. + traversablemap. + Atahighlevel,thepipelineis: + Theintuitionisthatapointonthisgraphisequallyfarfrom + known_walls = map_info.wall_mask_from_seen_and_symmetry() twoormoreobstacleboundaries.Wideopenroomsproduce + high-radiusgraphregions,whilenarrowcorridorsproduce + state = chokepoint_state.resume_or_init(known_walls) + low-radiusgraphchainsbetweenthoseregions. + state.step_until_budget() + samples = [] + if state.finished: for segment in free_space_boundary: + for choke in state.blocker_candidates: samples.extend(sample_segment(segment, spacing=1.0)) + register_chokepoint_tile(choke.tile, + choke.kind, + diagram = voronoi(samples) + choke.radius) + for edge in diagram.edges: + Theactualcomputationistooexpensivetorunfromscratch if edge.is_finite() and edge_inside_free_space(edge): + graph.add(edge) + insideasinglebuilderturn,sotheanalyzerisincremental. + Eachcallperformsaboundedamountofworkandstores + theintermediatestateglobally.Onceitfinishes,theresult- Foreachgraphvertex,wecomputeaclearanceradius:the + ingchokepointtilesarecacheduntiltheknownwallmap distancefromthatvertextothenearestfree-spaceboundary. + +===== PAGE 19 ===== + + + + + + CambridgeBattlecodePostmortem + + + + + + + + + + Unpruned Pruned + Figure27:GraphPruning + + + RegionNodes + Afterpruning,weidentifyregionnodes.Aregionnodeis + Figure26:Voronoion"Cubes" + eitheratopologicaljunctionorendpoint,oralocallymaxi- + malradiuspoint.Thelocallymaximalpointsareimportant + Thisradiusisthemainscalarusedbythelaterpruningand becausealongcorridorbetweentworoomsmayhaveno + chokepointextractionpasses. graphjunctionattheroomcenter.Themaximum-clearance + pointmarkstheroom-likepartofthatchain. + for v in graph.vertices: + radius[v] = distance(v, free_space.boundary) + Asmallradiusmeansthevertexliesinatightcorridor.A + largeradiusmeansthevertexliesinanopenarea.Choke- + pointsarenotsimplythesmallest-radiusvertices,because + wallcornersanddead-endbranchesalsohavetinyradius. + Theyarethesmallest-radiusverticesonmeaningfulpaths + betweenlargerregions. + GraphPruning + TherawVoronoigraphcontainsmanybranchesthatpoint + into wall corners or small pockets. These are not useful + chokepoints,becauseblockingthemdoesnotseparatetwo + importantregions.Weprunethesebrancheswithasimple + radiusmonotonicityrule. + Leavesareremovediftheyarenarrowerthantheirparent. + Figure28:RegionNodes + Repeatingthispeelsoffbranchesthatrunfromopenspace + intoadeadendorwallcorner,whilepreservingchainsthat + connecttwolargerareas. for v in graph.vertices: + if degree(v) ≠ 2: + active = all_vertices + regions.add(v) + queue = leaves(graph) + elif radius[v] ≥ REGION_MIN_RADIUS: + if no nearby vertex has radius ≥ radius[v]: + while queue: + regions.add(v) + leaf = queue.pop() + parent = only_neighbor(leaf) + The“nearby”testusesChebyshevdistanceboundedbythe + if radius[leaf] < radius[parent]: candidate’sownradius.Thismakestheregiontestscalewith + remove leaf from graph + localfeaturesize:inalargeroom,nearbymaximasuppress + if degree(parent) == 1: + queue.push(parent) eachother,whileinanarrowcorridor,smalllocalbumpsdo + notbecomeseparateregions. + +===== PAGE 20 ===== + + + + + + Wangetal. + ChokeExtraction Themainrulecomparesthechokeradiusagainsttheradiiof + + Onceregionnodesareknown,chokepointsareextractedby thetworegionsitconnects.Ifthebottleneckiscloseinsize + walkingeverychainbetweentwoadjacentregions.Along tothesurroundingregions,thenitisnotreallyabottleneck, + eachchain,thevertexwithminimumradiusisthebottleneck. sothetworegionsshouldbeconsideredonelargerregion: + for start, choke, end in chokes_by_decreasing_radius: + r_start = find(start) + r_end = find(end) + if r_start == r_end: + remove(choke) + continue + choke_r = radius[choke] + small_r = min(radius[r_start], radius[r_end]) + large_r = max(radius[r_start], radius[r_end]) + if (choke_r > ratio_small * small_r + or choke_r > ratio_large * large_r): + union(r_start, r_end) + remove(choke) + + Thisremovesmanyduplicatechokepointsinopenareas.For + Figure29:Chokepoints...? example, if two large chambers are connected by several + nearbyshallowdipsinradius,thewidestdipsaremerged + awayfirst,leavingonlythenarrowestmeaningfulseparator. + Uhoh.That’salotofchokepoints,includingsomewecannot + feasibly block. We can get around this issue by merging + We also added a second rule for the common case where + nearby regions together to remove some of the duplicate + asmallintermediateregioniscreatedbetweentwochoke- + chokepointswesee. + points.Ifaregionhasexactlytwoactivechokepointsandthe + The merge pass treats the region graph as a set of region largerofthemisstilltoowiderelativetotheregionitself,we + nodesconnectedbycandidatechokepoints.Achokepointis mergethroughit.Thispreventstinyartificialregionsfrom + onlyusefulifitissubstantiallynarrowerthantheregionsit producingpairsofredundantblockers: + separates;otherwiseitismorelikeaharmlesslocalwiggle + # after ignoring already-removed chokes + in the medial graph than a place where placing a blocker + for region in (r_start, r_end): + changesmovement. + active = active_chokes_touching(region) + Weprocesscandidatechokepointsfromwidesttonarrowest + if len(active) == 2: + andmaintainaunion-findoverregionnodes.Ifachokeis + if max(radius[c] for c in active) > + toowiderelativetoeitherofthetwoadjacentregions,we ratio_two_choke * radius[region]: + mergethoseregionsanddeletethechoke: union(r_start, r_end) + remove(choke) + parent = {region: region for region in region_nodes} break + def find(v): + while parent[v] ≠ v: Aftermerging,anyremovedchokeverticesaredeletedfrom + parent[v] = parent[parent[v]] + thechokeset,androundedblockerplacementsarerecom- + v = parent[v] + putedfromtheremainingchokes. + return v + for region in regions: + def union(a, b): for edge in outgoing_edges(region): + ra, rb = find(a), find(b) + path = walk_until_next_region(edge) + if ra == rb: + return if path reaches another region: + if radius[ra] ≥ radius[rb]: choke = argmin(path, key=radius) + parent[rb] = ra + if radius[choke] ≤ MAX_CHOKE_RADIUS: + else: + chokes.add((region, choke, other_region)) + parent[ra] = rb + +===== PAGE 21 ===== + + + + + + CambridgeBattlecodePostmortem + + Thegeometricpointisroundedbacktoabuildabletile.Can- + didateswhoseroundedfootprintsoverlapareclustered,and + wekeepthetightestchokeineachcluster. + for choke in chokes: + tile = round_to_tile(position[choke]) + kind = blocker_kind(radius[choke]) + if kind: + candidates.append((tile, kind, radius[choke])) + clusters = overlap_clusters(candidates) + for cluster in clusters: + keep min cluster by radius + UsingChokepointsIn-Game + Thechokepointoutputisconsumedpassivelybybuilders.We + donotcreateadedicated“chokepointstate”withahighscore + Figure30:AfterMerging + ceiling,becauseblockingterrainisusefulopportunistically + butshouldnotoverrideobviousattacks,repairs,orrouting + Thisgivesusagraph-levelinterpretationofachokepoint:it work.Instead,afterthemainstateruns,thebuilderchecks + isthenarrowestpointonaroutebetweentwolargerspaces. whetheritcancheaplyplaceachokepointblockernearby. + Thatdistinctioniswhatpreventseverywall-adjacentpixel + def try_passive_chokepoint_action_only(): + frombeingtreatedasimportant. if action_on_cooldown: + return False + Wethenconvertthegeometricchokeintoanin-gameblocker. + Verynarrowchokescanbeblockedbyasinglebarrier.Wider for choke in nearby_chokepoints(): + chokesusealauncher,becausealaunchercontrolsalarger if already_blocked(choke): + effectiveareabythrowingenemybuildersbackoutofthe continue + if can_build(choke.kind, choke.tile): + corridor. + build(choke.kind, choke.tile) + def blocker_kind(radius): return True + if radius ≤ WALL_CHOKE_RADIUS: + return BARRIER return False + if radius ≤ LAUNCHER_CHOKE_RADIUS: + return LAUNCHER Launchersbuiltatchokepointsuseaspecializedthrowpolicy. + return None Insteadofmaximizingrange,weattempttogettheopponent + builder stuck walking back into the chokepoint infinitely. + Wehencelaunchtothelastnthpositiontheywereseen. + if launcher_is_chokepoint_launcher: + enemy = best_enemy_builder_in_range() + dest = position_from_enemy_history(enemy) + if can_launch(enemy, dest): + launch(enemy, dest) + Thisisespeciallyeffectivewhentheenemyneedsabuilderto + healanattackrouteorrepairabrokenconveyor.Throwing + thebuilderbackwardturnsonelauncheractionintoseveral + turnsoflostwalkingtime. + Forinstance,thefigureabovedepictsachokepointlauncher + blockingahordeofopponentbotsfromhealingorreplac- + ingadefensivebuildingthatwasjustbroken;withoutthe + chokepointlauncher,theycouldeasilyhavepreventedthis. + However,ourbuilderisabletomovethroughfreelyand,a + Figure31:ClassifyLaunchers + fewturnslater,placeaturret. + +===== PAGE 22 ===== + + + + + + Wangetal. + + byitsskeletonandlocalradius.Thesurroundingfree-space + medial axis changes only slightly in these cases, but the + numberofgeneratedsitesdropsbyaconstantfactor. + for wall_component in components: + if is_large_compact_component(wall_component): + sites.extend(sample_wall_medial_axis(wall_component)) + else: + sites.extend(sample_wall_perimeter(wall_component)) + + Weonlyapplythisreductionincaseswhereitisunlikelyto + removeameaningfulfeature.Theoptimizationistherefore + conservative:itismainlyawaytoavoidoversamplinglarge + blocksofimpassableterrain. + InGame Tooling + PerformanceOptimization + Figure32:ChokepointBlocksOpponent + Runtimeoptimizationofourbotwascrucialnotjusttofit + withinthe2000𝜇sturnbudget,butalsotofreeuptimefor + IncrementalExecution ourchokepointcalculationstoruninthebackground. + Thefulldetectoristooexpensivetorecomputeeveryturn. Table 2 shows that all of our top ten runtime callers deal + Thein-gameversionthereforerunsasaresumablejob.Each withmapinfoorpathfinding. + phasestoresenoughstatetocontinuelater:obstaclecompo- + nents,boundarysamples,Voronoiedges,prunedgraphstate, Bitmasks + andfinalblockercandidates. Oneofourmainoptimizationswasusingbitmaskstostore + mapinfo,sinceusingbitoperationsonPythonbigintegers + while budget_left() and job.phase ≠ DONE: + if job.phase == BUILD_GEOMETRY: wassignificantlyfasterthaniteratingoverorindexinginto + job.build_some_components() arrays. + elif job.phase == SAMPLE_BOUNDARY: + job.sample_some_edges() Forexample,wecaniterateoverallsetbitsinamaskusing + elif job.phase == BUILD_GRAPH: thecanonicaltwo’s-complementLSBidiom: + job.process_some_voronoi_edges() + elif job.phase == PRUNE: while mask: + job.prune_some_leaves() lsb = mask & -mask # isolate lowest set bit + elif job.phase == EXTRACT: n = lsb.bit_length() - 1 # its position + job.extract_some_chokes() # ... use Position(n % W, n // W) ... + mask ^= lsb # clear and continue + Thismakesthecomputationcompatiblewiththe2000𝜇sunit + Thus,ifwebuildamaskcontainingpossibletargettiles(for + budget.Abuildercanspendasmallsliceoftimeadvancing + exploretargets,turretplacements,etc.),thetimecomplexity + theglobalchokepointjobwithoutriskingtimeout.Oncethe + is proportional to the number of set bits rather than the + jobfinishes,allbuilderssharethesameresult. + numberoftiles. + Medial-AxisWallReduction + LocalVariableHoisting + Oneoptimizationweaddedlaterwasreducingcertainwall + Pythonattributeandgloballookupsarenotfree,andsomeof + polygonstotheirmedialaxisbeforefeedingthemintothe + ourinnerloopsrunhundredsofthousandsoftimesperturn. + BWTA-styleruntime.Theoriginalboundary-samplingap- + Hence,everyhotfunctionbindsthemoduleandcontroller + proachsampleseveryexposedwallperimeter.Thisisaccu- + membersitneedstolocalsoncebeforetheloop: + rate,butlargesolidwallblobscancontributemanyperimeter + segmentswhosefinedetailhaslittlestrategiceffect. + Forsufficientlythickorcompactwallcomponents,wecan + replacethecomponent’sfullperimeterwithamuchsmaller + medial-axisrepresentative.Conceptually,insteadofdescrib- + ingeverypixelofthewalloutline,wedescribethewallmass + +===== PAGE 23 ===== + + + + + + CambridgeBattlecodePostmortem + Table2:Thetenlargestruntimeconsumers(ignoringchokepointconsumers),rankedbytotalselftime.Selftimes + areadditiveandsumtototalCPU,so%iseachfunction’sshareofthetotalselftime(≈6,483msover6,070profiled + turnsofonemap);thelistedtenaccountfor≈35%.𝜇s/callismeanselftimeperinvocation. + + Function Calls Self(ms) 𝜇s/call % + map_info:update_at 470,546 883.9 1.9 13.6 + map_info:update 6,070 336.7 55.5 5.2 + pathing:_claim_zone_on_passable 3,412 222.4 65.2 3.4 + map_info:_compute_route_targets 6,716 218.0 32.5 3.4 + pathing:bfs_move 3,655 123.1 33.7 1.9 + map_info:_carrying_expand 5,321 117.8 22.1 1.8 + Controller.get_tile_building_id 414,358 113.2 0.27 1.7 + map_info:expand_chebyshev 71,745 111.2 1.55 1.7 + pathing:_closest_impl 15,170 84.3 5.56 1.3 + map_info:update_move 2,752 76.6 27.8 1.2 + Furthermore,afteramove,update_movediffstheoldandnew + # bfs_move, before the frontier loop iterates: + visionmasksandcallsupdate_atonlyontilesthatbecame + bm_et = map_info._bm_et; + nlc = map_info._not_left_col; visible: + board = map_info._board_mask; + for tile in nearby: + bcost = barrier_cost; + bit = 1 << (tile.x + tile.y * W) + # update_at, binding hot Controller methods to locals: + new_visible |= bit + get_tile_env = rc.get_tile_env + if not (old_visible & bit): # only the delta + get_tile_building_id = rc.get_tile_building_id + update_at(tile) + get_hp = rc.get_hp + Thisreducesthenumberoftileswescanaftermovingfrom + alltileswithinvisiontoonlythenewlydiscoveredtiles. + Integer-IndexedTables + cambcenumsarenotinteger-valued,sousingthemasdictio- TTLCaches + narykeysorinfrozensetmembershiptestscostsahashper + Statesthatdependonexpensivecalculationstofindtarget + access.map_infoconvertseveryenumtoasequentialindex + tiles remember the tiles that failed. We use Time to Live + onceatimportandthereafteruseslistindexingandboolean (TTL)cachesthatstoretargettileswewanttoskipfor100 + lookuptablesinsteadofdictionarylookups. rounds,sowedon’twastecomputetryingtocalculateifa + recentlyfailedtargettileshouldbeactedonagain. + CachingandMemoization Debugging + Manyofourderivedbitmasks,calculatedtargets,etc.,are log.pybindslogatmoduleloadtooneoftwodefinitions + memoizedonthemapinfostructurestheydependon.We dependingonasingleboolean: + also memoize some calculations irrespective of local map + DEBUG_LOGGING = False + changes.Theharvestzone,whichisaVoronoifloodfillfrom + if DEBUG_LOGGING: + ourcoreagainstthepredictedenemycoreusedtoapproxi- + def log(*args, **kwargs): print(*args, **kwargs) + matesafeharvestlocations,onlyrunswhensymmetrypre- else: + dictionsupdate.Thecolumnandrowguardmasksandthe def log(*args, **kwargs): pass + eightper-facingdirectiontemplatesforsentinelsarelikewise + builtatinitandnotupdatedagain. Everylog(...)callsitethusbecomesano-opfunctioncall + whenweturntheflagoffbeforesubmittingourbot. + IncrementalUpdates Results + Weprioritizeupdatingourmapinfostructuresratherthan OurtournamentresultsaresummarizedinTable3.Sprints + fullyrebuildingthem.Forexample,update_atmaintainscon- weretheweeklytournamentsheldthroughoutthecompe- + veyoroutputandreverseconveyoroutputbitmasksasbuild- tition,whereteamscouldevaluatetheirperformanceand + ingsareupdated,ratherthanrescanningtheentiremap. gainabetterunderstandingofthemeta.TheInternational + +===== PAGE 24 ===== + + + + + + Wangetal. + + Qualifierswasatournamentforthetopeightteamsoutside + theUK,offeringachancetoadvancetotheGrandFinals. + TheGrandFinalswastheculminatingin-persontournament + for the top eight international and top eight UK teams to + watchthematchesplayout. + TheMemeandPongtournamentswerefor-funtournaments + heldaftertheGrandFinals.TheMemetournamentfeatured + severaledge-caseandtrollmaps.ThePongtournamentin- + volvedmin-maxingaxionitecollectionbyhardcodingstrate- + gies specifically for the Pong map. This map was unique + inthatthegamewouldendinaforceddrawifbothsides + avoidedplacingroadsnearthebarrierinthemiddle,since + thenneithersidecouldreachtheopponent’score. + Table3:TournamentResults. + + + Tournament Date Result + SprintOne 3/22 Top128 + SprintTwo 3/28 Top8 + SprintThree 4/5 Top4 + SprintFour 4/11 Top8 Figure34:TopeightseededteamsfortheGrandFinals. + SprintFive 4/19 Top4 + InternationalQualifiers 4/20 3rd oneslistedatthebeginning(suchasHermesandHephaestus, + GrandFinals 5/4 1st along with sub-versions of all of our bots) and were still + MemeTournament* 5/9 Top8 addinginnewstrategieslikeattackroutejusthoursbefore + PongTournament* 5/9 2nd thefinalsubmissiondeadline. + *For-funtournamentsplayedwithdifferentrule-sets. Overall,ourfinalsolutionisdifferentfrommostBattlecode + solutions,whichtendtohavepersistentstatesanddefined + Figure33showsourEloprogressionovertime.Wewentinto statetransitions.However,webelieveourmemorylessap- + theGrandFinalsasthefirstseed(Figure34showsthetop proachwasoneofthemainfactorsinourbotbeingvery + eightseededteamsbeforetheGrandFinals). dynamicandadaptable.Italsoallowedforalotofcomplex + behaviortoemergewithouthardcodingstrategies,which + madeourbotlesspronetooverfitting. + Intheend,ourteamhadalotoffuncompeting,andwe’re + alreadylookingforwardtothenextcompetition. + + + + + + + + Figure33:Eloratingoverthecourseofthecompetition. + + Conclusion + + Welearnedalotoverthecourseofthecompetitionthrough + trialanderror.Wehadmanymorebotversionsoutsideofthe + + + + + \ No newline at end of file diff --git a/transition40.zip b/transition40.zip new file mode 100644 index 0000000..7cd3790 Binary files /dev/null and b/transition40.zip differ diff --git a/waller_route_light_results.json b/waller_route_light_results.json new file mode 100644 index 0000000..63f83ae --- /dev/null +++ b/waller_route_light_results.json @@ -0,0 +1,25 @@ +{ + "bot": "exp_waller_route_light", + "status": "mechanism pass; promotion hold; not packaged, uploaded, activated, or copied to root", + "engine": "fcode 2.3.3 production package; 21 local maps; remote session expired", + "parent": "meta-generalist-v1", + "mechanism": "waller-only heal-gated facing-aware orphan conveyor repair from round 120; graph scan off ordinary builders' hot path", + "parent_gate_blocks": [ + {"games": 168, "wins": 83, "core_kills_for": 67, "core_kills_against": 74}, + {"games": 168, "wins": 86, "core_kills_for": 70, "core_kills_against": 68}, + {"games": 168, "wins": 87, "core_kills_for": 73, "core_kills_against": 69} + ], + "parent_gate_combined": { + "games": 504, + "wins": 256, + "win_rate": 0.5079, + "core_kills_for": 210, + "core_kills_against": 211, + "round_1000_games": 81, + "round_1000_wins": 45, + "round_1000_candidate_titanium_collected_mean": 3328.1, + "round_1000_parent_titanium_collected_mean": 3244.6 + }, + "rush_screen": {"opponent": "spar_rush", "games": 12, "wins": 7, "core_kills_for": 7, "core_kills_against": 5}, + "decision": "hold because the declared parent gate required nonnegative core-kill differential; unrelated-family gates and packaging were not run" +}