subham007 commited on
Commit
ddde047
Β·
verified Β·
1 Parent(s): 57dc7e0

Upload v12 agent (1529 lines, 58KB)

Browse files
Files changed (1) hide show
  1. submission.py +1529 -1
submission.py CHANGED
@@ -1 +1,1529 @@
1
- /app/submission.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Orbit Wars v12 β€” Full-Spectrum Domination Agent
3
+ ================================================
4
+ Built from v10/v11 base + every technique from 1200+ Elo public agents.
5
+
6
+ Major upgrades over v10/v11:
7
+ 1. 110-turn Forward Timeline Simulation β€” binary-search exact keep_needed
8
+ 2. Surplus-based dispatching β€” send EVERYTHING above keep_needed
9
+ 3. 7-mission priority system: reinforce > rescue > recapture > capture > snipe > swarm > crash_exploit
10
+ 4. Multi-source coordinated swarm attacks (2/3/4 sources, Β±2 turn sync)
11
+ 5. Snipe detection β€” arrive at neutrals right after enemy fleet battles
12
+ 6. Crash exploit β€” arrive after inter-enemy fleet collisions (4P)
13
+ 7. Hostile reinforcement prediction β€” estimate enemy can reinforce target
14
+ 8. Doomed planet evacuation β€” save ships from unsaveable planets
15
+ 9. Exposed planet detection β€” attack enemies who just launched fleets
16
+ 10. Dynamic game phases with domination tracking
17
+ 11. Weakest enemy targeting with elimination bonus (4P)
18
+ 12. Production snowball scoring: score = prod Γ— remaining_turns / cost
19
+ 13. Proactive defense with stacked multi-enemy threat detection
20
+ 14. Sun-aware fleet routing with iterative intercept solver
21
+ 15. Indirect wealth valuation (cluster bonus from nearby planets)
22
+ 16. Endgame consolidation to highest-production planets
23
+ """
24
+
25
+ import math
26
+ import time
27
+ from collections import defaultdict, namedtuple
28
+ from dataclasses import dataclass, field
29
+
30
+ # ═══════════════════════════════════════════
31
+ # CONSTANTS
32
+ # ═══════════════════════════════════════════
33
+ BOARD = 100.0
34
+ CENTER = 50.0
35
+ SUN_R = 10.0
36
+ SUN_SAFETY = 1.5
37
+ MAX_SPEED = 6.0
38
+ ROT_LIMIT = 50.0
39
+ TOTAL_STEPS = 500
40
+ HORIZON = 110
41
+ INTERCEPT_TOLERANCE = 1
42
+ LAUNCH_CLEARANCE = 0.1
43
+
44
+ # Phase thresholds
45
+ EARLY_TURN_LIMIT = 40
46
+ OPENING_TURN_LIMIT = 80
47
+ LATE_REMAINING_TURNS = 70
48
+ VERY_LATE_REMAINING_TURNS = 25
49
+ TOTAL_WAR_REMAINING_TURNS = 55
50
+
51
+ # Opening filters
52
+ SAFE_OPENING_PROD_THRESHOLD = 4
53
+ SAFE_OPENING_TURN_LIMIT = 10
54
+ ROTATING_OPENING_MAX_TURNS = 13
55
+ ROTATING_OPENING_LOW_PROD = 2
56
+ FOUR_PLAYER_ROTATING_REACTION_GAP = 3
57
+ FOUR_PLAYER_ROTATING_SEND_RATIO = 0.55
58
+ FOUR_PLAYER_ROTATING_TURN_LIMIT = 10
59
+
60
+ # Comet
61
+ COMET_MAX_CHASE_TURNS = 10
62
+
63
+ # Scoring weights
64
+ ATTACK_COST_TURN_WEIGHT = 0.50
65
+ SNIPE_COST_TURN_WEIGHT = 0.42
66
+ INDIRECT_VALUE_SCALE = 0.15
67
+ INDIRECT_FRIENDLY_WEIGHT = 0.35
68
+ INDIRECT_NEUTRAL_WEIGHT = 0.9
69
+ INDIRECT_ENEMY_WEIGHT = 1.25
70
+
71
+ # Value multipliers
72
+ STATIC_NEUTRAL_VALUE_MULT = 1.4
73
+ STATIC_HOSTILE_VALUE_MULT = 1.65
74
+ ROTATING_OPENING_VALUE_MULT = 0.9
75
+ HOSTILE_TARGET_VALUE_MULT = 2.05
76
+ OPENING_HOSTILE_TARGET_VALUE_MULT = 1.55
77
+ SAFE_NEUTRAL_VALUE_MULT = 1.2
78
+ CONTESTED_NEUTRAL_VALUE_MULT = 0.7
79
+ EARLY_NEUTRAL_VALUE_MULT = 1.2
80
+ COMET_VALUE_MULT = 0.65
81
+ SNIPE_VALUE_MULT = 1.12
82
+ SWARM_VALUE_MULT = 1.05
83
+ REINFORCE_VALUE_MULT = 1.35
84
+ CRASH_EXPLOIT_VALUE_MULT = 1.18
85
+ FINISHING_HOSTILE_VALUE_MULT = 1.3
86
+ BEHIND_ROTATING_NEUTRAL_VALUE_MULT = 0.92
87
+ EXPOSED_PLANET_VALUE_MULT = 2.0
88
+ WEAKEST_ENEMY_VALUE_MULT_4P = 1.5
89
+ GANG_UP_VALUE_MULT = 1.4
90
+
91
+ # Margins
92
+ SAFE_NEUTRAL_MARGIN = 2
93
+ CONTESTED_NEUTRAL_MARGIN = 2
94
+ NEUTRAL_MARGIN_BASE = 2
95
+ NEUTRAL_MARGIN_PROD_WEIGHT = 2
96
+ NEUTRAL_MARGIN_CAP = 8
97
+ HOSTILE_MARGIN_BASE = 3
98
+ HOSTILE_MARGIN_PROD_WEIGHT = 2
99
+ HOSTILE_MARGIN_CAP = 12
100
+ HOSTILE_REINFORCE_HORIZON = 8
101
+ HOSTILE_REINFORCE_RATIO = 0.25
102
+ HOSTILE_REINFORCE_CAP = 15
103
+ STATIC_TARGET_MARGIN = 4
104
+ CONTESTED_TARGET_MARGIN = 5
105
+ FOUR_PLAYER_TARGET_MARGIN = 2
106
+ LONG_TRAVEL_MARGIN_START = 18
107
+ LONG_TRAVEL_MARGIN_DIVISOR = 3
108
+ LONG_TRAVEL_MARGIN_CAP = 8
109
+ COMET_MARGIN_RELIEF = 6
110
+ FINISHING_HOSTILE_SEND_BONUS = 5
111
+
112
+ # Score multipliers
113
+ STATIC_TARGET_SCORE_MULT = 1.18
114
+ EARLY_STATIC_NEUTRAL_SCORE_MULT = 1.25
115
+ FOUR_PLAYER_ROTATING_NEUTRAL_SCORE_MULT = 0.84
116
+ DENSE_STATIC_NEUTRAL_COUNT = 4
117
+ DENSE_ROTATING_NEUTRAL_SCORE_MULT = 0.86
118
+ SNIPE_SCORE_MULT = 1.12
119
+ SWARM_SCORE_MULT = 1.06
120
+
121
+ # Fleet minimums
122
+ PARTIAL_SOURCE_MIN_SHIPS = 6
123
+ FOLLOWUP_MIN_SHIPS = 8
124
+ MULTI_SOURCE_TOP_K = 8
125
+ MULTI_SOURCE_ETA_TOLERANCE = 2
126
+ MULTI_SOURCE_PLAN_PENALTY = 0.97
127
+ HOSTILE_SWARM_ETA_TOLERANCE = 1
128
+ THREE_SOURCE_SWARM_ENABLED = True
129
+ THREE_SOURCE_MIN_TARGET_SHIPS = 20
130
+ THREE_SOURCE_ETA_TOLERANCE = 1
131
+ THREE_SOURCE_PLAN_PENALTY = 0.93
132
+ FOUR_SOURCE_SWARM_ENABLED = True
133
+ FOUR_SOURCE_ETA_TOLERANCE = 2
134
+ FOUR_SOURCE_MIN_TARGET_SHIPS = 40
135
+ FOUR_SOURCE_PLAN_PENALTY = 0.91
136
+
137
+ # Reinforcement
138
+ REINFORCE_ENABLED = True
139
+ REINFORCE_MIN_PRODUCTION = 2
140
+ REINFORCE_MAX_TRAVEL_TURNS = 22
141
+ REINFORCE_SAFETY_MARGIN = 2
142
+ REINFORCE_MAX_SOURCE_FRACTION = 0.75
143
+ REINFORCE_MIN_FUTURE_TURNS = 40
144
+
145
+ # Defense
146
+ PROACTIVE_DEFENSE_HORIZON = 12
147
+ PROACTIVE_DEFENSE_RATIO = 0.28
148
+ MULTI_ENEMY_PROACTIVE_HORIZON = 14
149
+ MULTI_ENEMY_PROACTIVE_RATIO = 0.35
150
+ MULTI_ENEMY_STACK_WINDOW = 4
151
+
152
+ # Crash exploit
153
+ CRASH_EXPLOIT_ENABLED = True
154
+ CRASH_EXPLOIT_MIN_TOTAL_SHIPS = 7
155
+ CRASH_EXPLOIT_ETA_WINDOW = 3
156
+ CRASH_EXPLOIT_POST_CRASH_DELAY = 1
157
+
158
+ # Elimination
159
+ LATE_IMMEDIATE_SHIP_VALUE = 0.75
160
+ WEAK_ENEMY_THRESHOLD = 110
161
+ ELIMINATION_BONUS = 55.0
162
+
163
+ # Domination thresholds
164
+ BEHIND_DOMINATION = -0.20
165
+ AHEAD_DOMINATION = 0.15
166
+ FINISHING_DOMINATION = 0.28
167
+ FINISHING_PROD_RATIO = 1.15
168
+ AHEAD_ATTACK_MARGIN_BONUS = 0.12
169
+ BEHIND_ATTACK_MARGIN_PENALTY = 0.05
170
+ FINISHING_ATTACK_MARGIN_BONUS = 0.12
171
+
172
+ # Evacuation
173
+ DOOMED_EVAC_TURN_LIMIT = 24
174
+ DOOMED_MIN_SHIPS = 8
175
+
176
+ # Rear forwarding
177
+ REAR_SOURCE_MIN_SHIPS = 16
178
+ REAR_DISTANCE_RATIO = 1.25
179
+ REAR_SEND_RATIO_TWO_PLAYER = 0.62
180
+ REAR_SEND_RATIO_FOUR_PLAYER = 0.60
181
+ REAR_SEND_MIN_SHIPS = 10
182
+ REAR_MAX_TRAVEL_TURNS = 40
183
+
184
+ # Time budget
185
+ SOFT_ACT_DEADLINE = 0.82
186
+
187
+ # ═══════════════════════════════════════════
188
+ # TYPES
189
+ # ═══════════════════════════════════════════
190
+ try:
191
+ from kaggle_environments.envs.orbit_wars.orbit_wars import Planet, Fleet
192
+ except ImportError:
193
+ Planet = namedtuple("Planet", ["id", "owner", "x", "y", "radius", "ships", "production"])
194
+ Fleet = namedtuple("Fleet", ["id", "owner", "x", "y", "angle", "from_planet_id", "ships"])
195
+
196
+
197
+ @dataclass(frozen=True)
198
+ class ShotOption:
199
+ score: float
200
+ src_id: int
201
+ target_id: int
202
+ angle: float
203
+ turns: int
204
+ needed: int
205
+ send_cap: int
206
+ mission: str = "capture"
207
+
208
+
209
+ @dataclass
210
+ class Mission:
211
+ kind: str
212
+ score: float
213
+ target_id: int
214
+ turns: int
215
+ options: list = field(default_factory=list)
216
+
217
+
218
+ # ═══════════════════════════════════════════
219
+ # PHYSICS
220
+ # ═══════════════════════════════════════════
221
+ def dist(ax, ay, bx, by):
222
+ return math.hypot(ax - bx, ay - by)
223
+
224
+
225
+ def orbital_radius(planet):
226
+ return dist(planet.x, planet.y, CENTER, CENTER)
227
+
228
+
229
+ def is_static_planet(planet):
230
+ return orbital_radius(planet) + planet.radius >= ROT_LIMIT
231
+
232
+
233
+ def fleet_speed(ships):
234
+ if ships <= 1:
235
+ return 1.0
236
+ ratio = math.log(ships) / math.log(1000.0)
237
+ ratio = max(0.0, min(1.0, ratio))
238
+ return 1.0 + (MAX_SPEED - 1.0) * (ratio ** 1.5)
239
+
240
+
241
+ def point_to_segment_distance(px, py, x1, y1, x2, y2):
242
+ dx = x2 - x1
243
+ dy = y2 - y1
244
+ seg_len_sq = dx * dx + dy * dy
245
+ if seg_len_sq <= 1e-9:
246
+ return dist(px, py, x1, y1)
247
+ t = ((px - x1) * dx + (py - y1) * dy) / seg_len_sq
248
+ t = max(0.0, min(1.0, t))
249
+ return dist(px, py, x1 + t * dx, y1 + t * dy)
250
+
251
+
252
+ def segment_hits_sun(x1, y1, x2, y2, safety=SUN_SAFETY):
253
+ return point_to_segment_distance(CENTER, CENTER, x1, y1, x2, y2) < SUN_R + safety
254
+
255
+
256
+ def launch_point(sx, sy, sr, angle):
257
+ clearance = sr + LAUNCH_CLEARANCE
258
+ return sx + math.cos(angle) * clearance, sy + math.sin(angle) * clearance
259
+
260
+
261
+ def safe_angle_and_distance(sx, sy, sr, tx, ty, tr):
262
+ angle = math.atan2(ty - sy, tx - sx)
263
+ start_x, start_y = launch_point(sx, sy, sr, angle)
264
+ hit_distance = max(0.0, dist(sx, sy, tx, ty) - (sr + LAUNCH_CLEARANCE) - tr)
265
+ end_x = start_x + math.cos(angle) * hit_distance
266
+ end_y = start_y + math.sin(angle) * hit_distance
267
+ if segment_hits_sun(start_x, start_y, end_x, end_y):
268
+ return None
269
+ return angle, hit_distance
270
+
271
+
272
+ def estimate_arrival(sx, sy, sr, tx, ty, tr, ships):
273
+ safe = safe_angle_and_distance(sx, sy, sr, tx, ty, tr)
274
+ if safe is None:
275
+ return None
276
+ angle, total_d = safe
277
+ turns = max(1, int(math.ceil(total_d / fleet_speed(max(1, ships)))))
278
+ return angle, turns
279
+
280
+
281
+ def travel_time(sx, sy, sr, tx, ty, tr, ships):
282
+ est = estimate_arrival(sx, sy, sr, tx, ty, tr, ships)
283
+ if est is None:
284
+ return 10 ** 9
285
+ return est[1]
286
+
287
+
288
+ # ═══════════════════════════════════════════
289
+ # POSITION PREDICTION
290
+ # ═══════════════════════════════════════════
291
+ def predict_planet_position(planet, initial_by_id, angular_velocity, turns):
292
+ init = initial_by_id.get(planet.id)
293
+ if init is None:
294
+ return planet.x, planet.y
295
+ r = dist(init.x, init.y, CENTER, CENTER)
296
+ if r + init.radius >= ROT_LIMIT:
297
+ return planet.x, planet.y
298
+ cur_ang = math.atan2(planet.y - CENTER, planet.x - CENTER)
299
+ new_ang = cur_ang + angular_velocity * turns
300
+ return CENTER + r * math.cos(new_ang), CENTER + r * math.sin(new_ang)
301
+
302
+
303
+ def predict_comet_position(planet_id, comets, turns):
304
+ for group in comets:
305
+ pids = group.get("planet_ids", [])
306
+ if planet_id not in pids:
307
+ continue
308
+ idx = pids.index(planet_id)
309
+ paths = group.get("paths", [])
310
+ path_index = group.get("path_index", 0)
311
+ if idx >= len(paths):
312
+ return None
313
+ path = paths[idx]
314
+ future_idx = path_index + int(turns)
315
+ if 0 <= future_idx < len(path):
316
+ return path[future_idx][0], path[future_idx][1]
317
+ return None
318
+ return None
319
+
320
+
321
+ def comet_remaining_life(planet_id, comets):
322
+ for group in comets:
323
+ pids = group.get("planet_ids", [])
324
+ if planet_id not in pids:
325
+ continue
326
+ idx = pids.index(planet_id)
327
+ paths = group.get("paths", [])
328
+ path_index = group.get("path_index", 0)
329
+ if idx < len(paths):
330
+ return max(0, len(paths[idx]) - path_index)
331
+ return 0
332
+
333
+
334
+ def predict_target_position(target, turns, initial_by_id, ang_vel, comets, comet_ids):
335
+ if target.id in comet_ids:
336
+ return predict_comet_position(target.id, comets, turns)
337
+ return predict_planet_position(target, initial_by_id, ang_vel, turns)
338
+
339
+
340
+ def target_can_move(target, initial_by_id, comet_ids):
341
+ if target.id in comet_ids:
342
+ return True
343
+ init = initial_by_id.get(target.id)
344
+ if init is None:
345
+ return False
346
+ r = dist(init.x, init.y, CENTER, CENTER)
347
+ return r + init.radius < ROT_LIMIT
348
+
349
+
350
+ # ═══════════════════════════════════════════
351
+ # INTERCEPT SOLVER (from 1200+ agents)
352
+ # ═══════════════════════════════════════════
353
+ def search_safe_intercept(src, target, ships, initial_by_id, ang_vel, comets, comet_ids):
354
+ best = None
355
+ best_score = None
356
+ max_turns = min(HORIZON, 60)
357
+ if target.id in comet_ids:
358
+ max_turns = min(max_turns, max(0, comet_remaining_life(target.id, comets) - 1))
359
+ for candidate_turns in range(1, max_turns + 1):
360
+ pos = predict_target_position(target, candidate_turns, initial_by_id, ang_vel, comets, comet_ids)
361
+ if pos is None:
362
+ continue
363
+ est = estimate_arrival(src.x, src.y, src.radius, pos[0], pos[1], target.radius, ships)
364
+ if est is None:
365
+ continue
366
+ _, turns = est
367
+ if abs(turns - candidate_turns) > INTERCEPT_TOLERANCE:
368
+ continue
369
+ actual_turns = max(turns, candidate_turns)
370
+ actual_pos = predict_target_position(target, actual_turns, initial_by_id, ang_vel, comets, comet_ids)
371
+ if actual_pos is None:
372
+ continue
373
+ confirm = estimate_arrival(src.x, src.y, src.radius, actual_pos[0], actual_pos[1], target.radius, ships)
374
+ if confirm is None:
375
+ continue
376
+ delta = abs(confirm[1] - actual_turns)
377
+ if delta > INTERCEPT_TOLERANCE:
378
+ continue
379
+ score = (delta, confirm[1], candidate_turns)
380
+ if best is None or score < best_score:
381
+ best_score = score
382
+ best = (confirm[0], confirm[1], actual_pos[0], actual_pos[1])
383
+ return best
384
+
385
+
386
+ def aim_with_prediction(src, target, ships, initial_by_id, ang_vel, comets, comet_ids):
387
+ """Iterative intercept solver: 5 Newton-Raphson-like iterations + fallback search."""
388
+ est = estimate_arrival(src.x, src.y, src.radius, target.x, target.y, target.radius, ships)
389
+ if est is None:
390
+ if not target_can_move(target, initial_by_id, comet_ids):
391
+ return None
392
+ return search_safe_intercept(src, target, ships, initial_by_id, ang_vel, comets, comet_ids)
393
+ tx, ty = target.x, target.y
394
+ for _ in range(5):
395
+ _, turns = est
396
+ pos = predict_target_position(target, turns, initial_by_id, ang_vel, comets, comet_ids)
397
+ if pos is None:
398
+ return None
399
+ ntx, nty = pos
400
+ next_est = estimate_arrival(src.x, src.y, src.radius, ntx, nty, target.radius, ships)
401
+ if next_est is None:
402
+ if not target_can_move(target, initial_by_id, comet_ids):
403
+ return None
404
+ return search_safe_intercept(src, target, ships, initial_by_id, ang_vel, comets, comet_ids)
405
+ if abs(ntx - tx) < 0.3 and abs(nty - ty) < 0.3 and abs(next_est[1] - turns) <= INTERCEPT_TOLERANCE:
406
+ return next_est[0], next_est[1], ntx, nty
407
+ tx, ty = ntx, nty
408
+ est = next_est
409
+ final_est = estimate_arrival(src.x, src.y, src.radius, tx, ty, target.radius, ships)
410
+ if final_est is None:
411
+ return search_safe_intercept(src, target, ships, initial_by_id, ang_vel, comets, comet_ids)
412
+ return final_est[0], final_est[1], tx, ty
413
+
414
+
415
+ # ═══════════════════════════════════════════
416
+ # FLEET TRACKING (from v10/v11)
417
+ # ═══════════════════════════════════════════
418
+ def fleet_target_planet(fleet, planets):
419
+ """Determine which planet a fleet is heading toward."""
420
+ best_planet = None
421
+ best_time = 1e9
422
+ dir_x = math.cos(fleet.angle)
423
+ dir_y = math.sin(fleet.angle)
424
+ speed = fleet_speed(fleet.ships)
425
+ for planet in planets:
426
+ dx = planet.x - fleet.x
427
+ dy = planet.y - fleet.y
428
+ proj = dx * dir_x + dy * dir_y
429
+ if proj < 0:
430
+ continue
431
+ perp_sq = dx * dx + dy * dy - proj * proj
432
+ radius_sq = planet.radius * planet.radius
433
+ if perp_sq >= radius_sq:
434
+ continue
435
+ hit_d = max(0.0, proj - math.sqrt(max(0.0, radius_sq - perp_sq)))
436
+ turns = hit_d / speed
437
+ if turns <= HORIZON and turns < best_time:
438
+ best_time = turns
439
+ best_planet = planet
440
+ if best_planet is None:
441
+ return None, None
442
+ return best_planet, int(math.ceil(best_time))
443
+
444
+
445
+ def build_arrival_ledger(fleets, planets):
446
+ """Build dict: planet_id -> [(eta, owner, ships)] for all fleets."""
447
+ arrivals = {p.id: [] for p in planets}
448
+ for fleet in fleets:
449
+ target, eta = fleet_target_planet(fleet, planets)
450
+ if target is None:
451
+ continue
452
+ arrivals[target.id].append((eta, fleet.owner, int(fleet.ships)))
453
+ return arrivals
454
+
455
+
456
+ # ═══════════════════════════════════════════
457
+ # FORWARD BATTLE SIMULATION (from 1200+ agents)
458
+ # ═══════════════════════════════════════════
459
+ def resolve_arrival_event(owner, garrison, arrivals):
460
+ """Engine-accurate combat resolution: group by owner, top-2 duel."""
461
+ by_owner = {}
462
+ for _, attacker_owner, ships in arrivals:
463
+ by_owner[attacker_owner] = by_owner.get(attacker_owner, 0) + ships
464
+ if not by_owner:
465
+ return owner, max(0.0, garrison)
466
+ sorted_players = sorted(by_owner.items(), key=lambda x: x[1], reverse=True)
467
+ top_owner, top_ships = sorted_players[0]
468
+ if len(sorted_players) > 1:
469
+ second_ships = sorted_players[1][1]
470
+ if top_ships == second_ships:
471
+ survivor_owner = -1
472
+ survivor_ships = 0
473
+ else:
474
+ survivor_owner = top_owner
475
+ survivor_ships = top_ships - second_ships
476
+ else:
477
+ survivor_owner = top_owner
478
+ survivor_ships = top_ships
479
+ if survivor_ships <= 0:
480
+ return owner, max(0.0, garrison)
481
+ if owner == survivor_owner:
482
+ return owner, garrison + survivor_ships
483
+ garrison -= survivor_ships
484
+ if garrison < 0:
485
+ return survivor_owner, -garrison
486
+ return owner, garrison
487
+
488
+
489
+ def simulate_planet_timeline(planet, arrivals, player, horizon):
490
+ """
491
+ Simulate a planet's ownership/garrison over `horizon` turns.
492
+ Binary-search exact keep_needed β€” minimum garrison to hold the planet.
493
+ This is THE fundamental upgrade from v10/v11.
494
+ """
495
+ horizon = max(0, int(math.ceil(horizon)))
496
+ events = []
497
+ for turns, owner, ships in arrivals:
498
+ if ships <= 0:
499
+ continue
500
+ eta = max(1, int(math.ceil(turns)))
501
+ if eta > horizon:
502
+ continue
503
+ events.append((eta, owner, int(ships)))
504
+ events.sort()
505
+ by_turn = defaultdict(list)
506
+ for item in events:
507
+ by_turn[item[0]].append(item)
508
+
509
+ owner = planet.owner
510
+ garrison = float(planet.ships)
511
+ owner_at = {0: owner}
512
+ ships_at = {0: max(0.0, garrison)}
513
+ first_enemy = None
514
+ fall_turn = None
515
+
516
+ for turn in range(1, horizon + 1):
517
+ if owner != -1:
518
+ garrison += planet.production
519
+ group = by_turn.get(turn, [])
520
+ prev_owner = owner
521
+ if group:
522
+ if prev_owner == player and first_enemy is None:
523
+ if any(item[1] not in (-1, player) for item in group):
524
+ first_enemy = turn
525
+ owner, garrison = resolve_arrival_event(owner, garrison, group)
526
+ if prev_owner == player and owner != player and fall_turn is None:
527
+ fall_turn = turn
528
+ owner_at[turn] = owner
529
+ ships_at[turn] = max(0.0, garrison)
530
+
531
+ keep_needed = 0
532
+ holds_full = True
533
+
534
+ if planet.owner == player:
535
+ def survives_with_keep(keep):
536
+ sim_owner = planet.owner
537
+ sim_garrison = float(keep)
538
+ for turn in range(1, horizon + 1):
539
+ if sim_owner != -1:
540
+ sim_garrison += planet.production
541
+ group = by_turn.get(turn, [])
542
+ if group:
543
+ sim_owner, sim_garrison = resolve_arrival_event(sim_owner, sim_garrison, group)
544
+ if sim_owner != player:
545
+ return False
546
+ return sim_owner == player
547
+
548
+ if survives_with_keep(int(planet.ships)):
549
+ lo, hi = 0, int(planet.ships)
550
+ while lo < hi:
551
+ mid = (lo + hi) // 2
552
+ if survives_with_keep(mid):
553
+ hi = mid
554
+ else:
555
+ lo = mid + 1
556
+ keep_needed = lo
557
+ else:
558
+ holds_full = False
559
+ keep_needed = int(planet.ships)
560
+
561
+ return {
562
+ "owner_at": owner_at,
563
+ "ships_at": ships_at,
564
+ "keep_needed": keep_needed,
565
+ "first_enemy": first_enemy,
566
+ "fall_turn": fall_turn,
567
+ "holds_full": holds_full,
568
+ "horizon": horizon,
569
+ }
570
+
571
+
572
+ def state_at_timeline(timeline, arrival_turn):
573
+ turn = max(0, int(math.ceil(arrival_turn)))
574
+ turn = min(turn, timeline["horizon"])
575
+ owner = timeline["owner_at"].get(turn, timeline["owner_at"][timeline["horizon"]])
576
+ ships = timeline["ships_at"].get(turn, timeline["ships_at"][timeline["horizon"]])
577
+ return owner, max(0.0, ships)
578
+
579
+
580
+ # ═══════════════════════════════════════════
581
+ # WORLD MODEL
582
+ # ═══════════════════════════════════════════
583
+ def count_players(planets, fleets):
584
+ owners = set()
585
+ for p in planets:
586
+ if p.owner != -1:
587
+ owners.add(p.owner)
588
+ for f in fleets:
589
+ owners.add(f.owner)
590
+ return max(2, len(owners))
591
+
592
+
593
+ def indirect_wealth(planet, planets, player):
594
+ wealth = 0.0
595
+ for other in planets:
596
+ if other.id == planet.id:
597
+ continue
598
+ d = dist(planet.x, planet.y, other.x, other.y)
599
+ if d < 1:
600
+ continue
601
+ factor = other.production / (d + 12.0)
602
+ if other.owner == player:
603
+ wealth += factor * INDIRECT_FRIENDLY_WEIGHT
604
+ elif other.owner == -1:
605
+ wealth += factor * INDIRECT_NEUTRAL_WEIGHT
606
+ else:
607
+ wealth += factor * INDIRECT_ENEMY_WEIGHT
608
+ return wealth
609
+
610
+
611
+ def detect_exposed_planets(fleets, enemy_planets):
612
+ """Detect enemy planets that just launched most of their ships."""
613
+ exposed = set()
614
+ for planet in enemy_planets:
615
+ outbound = sum(
616
+ int(f.ships) for f in fleets
617
+ if f.owner == planet.owner and f.from_planet_id == planet.id and f.ships >= 5
618
+ )
619
+ if outbound >= 12 and outbound >= planet.ships * 0.8:
620
+ exposed.add(planet.id)
621
+ return exposed
622
+
623
+
624
+ def detect_enemy_crashes(arrivals_by_planet, player, eta_window):
625
+ """Detect where two enemy fleets will crash into the same planet."""
626
+ crashes = []
627
+ for target_id, arrivals in arrivals_by_planet.items():
628
+ enemy_events = [
629
+ (int(math.ceil(eta)), owner, int(ships))
630
+ for eta, owner, ships in arrivals
631
+ if owner not in (-1, player) and ships > 0
632
+ ]
633
+ if len(enemy_events) < 2:
634
+ continue
635
+ by_owner = defaultdict(list)
636
+ for eta, owner, ships in enemy_events:
637
+ by_owner[owner].append((eta, ships))
638
+ if len(by_owner) < 2:
639
+ continue
640
+ enemy_events.sort()
641
+ for i in range(len(enemy_events)):
642
+ for j in range(i + 1, len(enemy_events)):
643
+ eta_a, owner_a, ships_a = enemy_events[i]
644
+ eta_b, owner_b, ships_b = enemy_events[j]
645
+ if owner_a == owner_b:
646
+ continue
647
+ if abs(eta_a - eta_b) > eta_window:
648
+ break
649
+ if ships_a + ships_b < CRASH_EXPLOIT_MIN_TOTAL_SHIPS:
650
+ continue
651
+ crashes.append({
652
+ "target_id": target_id,
653
+ "crash_turn": max(eta_a, eta_b),
654
+ "total_ships": ships_a + ships_b,
655
+ })
656
+ break
657
+ else:
658
+ continue
659
+ break
660
+ return crashes
661
+
662
+
663
+ class WorldModel:
664
+ """Central game state with precomputed timelines, budgets, and caches."""
665
+
666
+ def __init__(self, player, step, planets, fleets, initial_by_id, ang_vel, comets, comet_ids):
667
+ self.player = player
668
+ self.step = step
669
+ self.planets = planets
670
+ self.fleets = fleets
671
+ self.initial_by_id = initial_by_id
672
+ self.ang_vel = ang_vel
673
+ self.comets = comets
674
+ self.comet_ids = set(comet_ids)
675
+
676
+ self.planet_by_id = {p.id: p for p in planets}
677
+ self.my_planets = [p for p in planets if p.owner == player]
678
+ self.enemy_planets = [p for p in planets if p.owner not in (-1, player)]
679
+ self.neutral_planets = [p for p in planets if p.owner == -1]
680
+ self.static_neutral_planets = [p for p in self.neutral_planets if is_static_planet(p)]
681
+
682
+ self.num_players = count_players(planets, fleets)
683
+ self.remaining_steps = max(1, TOTAL_STEPS - step)
684
+ self.is_early = step < EARLY_TURN_LIMIT
685
+ self.is_opening = step < OPENING_TURN_LIMIT
686
+ self.is_late = self.remaining_steps < LATE_REMAINING_TURNS
687
+ self.is_very_late = self.remaining_steps < VERY_LATE_REMAINING_TURNS
688
+ self.is_total_war = self.remaining_steps < TOTAL_WAR_REMAINING_TURNS
689
+ self.is_four_player = self.num_players >= 4
690
+
691
+ # Per-owner strength
692
+ self.owner_strength = defaultdict(int)
693
+ self.owner_production = defaultdict(int)
694
+ for p in planets:
695
+ if p.owner != -1:
696
+ self.owner_strength[p.owner] += int(p.ships)
697
+ self.owner_production[p.owner] += int(p.production)
698
+ for f in fleets:
699
+ self.owner_strength[f.owner] += int(f.ships)
700
+
701
+ self.my_total = self.owner_strength.get(player, 0)
702
+ self.enemy_total = sum(s for o, s in self.owner_strength.items() if o != player)
703
+ self.max_enemy_strength = max(
704
+ (s for o, s in self.owner_strength.items() if o != player), default=0
705
+ )
706
+ self.my_prod = self.owner_production.get(player, 0)
707
+ self.enemy_prod = sum(p for o, p in self.owner_production.items() if o != player)
708
+
709
+ # Weakest enemy
710
+ enemy_owners = set(p.owner for p in self.enemy_planets)
711
+ if enemy_owners:
712
+ self._weakest_enemy = min(
713
+ enemy_owners,
714
+ key=lambda o: self.owner_strength.get(o, 0) + self.owner_production.get(o, 0) * 15,
715
+ )
716
+ else:
717
+ self._weakest_enemy = None
718
+
719
+ # Build arrival ledger and timelines
720
+ self.arrivals_by_planet = build_arrival_ledger(fleets, planets)
721
+ self.base_timeline = {}
722
+ for p in planets:
723
+ self.base_timeline[p.id] = simulate_planet_timeline(
724
+ p, self.arrivals_by_planet[p.id], player, HORIZON
725
+ )
726
+
727
+ # Indirect wealth
728
+ self.indirect_wealth_map = {p.id: indirect_wealth(p, planets, player) for p in planets}
729
+
730
+ # Exposed and crash detection
731
+ self.exposed_planet_ids = detect_exposed_planets(fleets, self.enemy_planets)
732
+ if CRASH_EXPLOIT_ENABLED and self.is_four_player:
733
+ self.enemy_crashes = detect_enemy_crashes(
734
+ self.arrivals_by_planet, player, CRASH_EXPLOIT_ETA_WINDOW
735
+ )
736
+ else:
737
+ self.enemy_crashes = []
738
+
739
+ # Shot cache
740
+ self.shot_cache = {}
741
+
742
+ # Compute defense budgets
743
+ self.reserve, self.available, self.doomed, self.threatened = self._compute_defense()
744
+
745
+ def _compute_defense(self):
746
+ """Compute exact keep_needed + proactive defense for each planet."""
747
+ reserve = {}
748
+ available = {}
749
+ doomed = set()
750
+ threatened = {}
751
+
752
+ for planet in self.my_planets:
753
+ tl = self.base_timeline[planet.id]
754
+ exact_keep = tl["keep_needed"]
755
+
756
+ # Proactive: anticipate nearby enemies
757
+ proactive = 0
758
+ for enemy in self.enemy_planets:
759
+ eta = travel_time(enemy.x, enemy.y, enemy.radius, planet.x, planet.y, planet.radius, max(1, enemy.ships))
760
+ if eta <= PROACTIVE_DEFENSE_HORIZON:
761
+ proactive = max(proactive, int(enemy.ships * PROACTIVE_DEFENSE_RATIO))
762
+
763
+ # Stacked multi-enemy threat
764
+ threats = []
765
+ for enemy in self.enemy_planets:
766
+ eta = travel_time(enemy.x, enemy.y, enemy.radius, planet.x, planet.y, planet.radius, max(1, enemy.ships))
767
+ if eta <= MULTI_ENEMY_PROACTIVE_HORIZON:
768
+ threats.append((eta, int(enemy.ships)))
769
+ if threats:
770
+ threats.sort()
771
+ best_stacked = 0
772
+ left = running = 0
773
+ for right in range(len(threats)):
774
+ running += threats[right][1]
775
+ while threats[right][0] - threats[left][0] > MULTI_ENEMY_STACK_WINDOW:
776
+ running -= threats[left][1]
777
+ left += 1
778
+ best_stacked = max(best_stacked, running)
779
+ proactive = max(proactive, int(best_stacked * MULTI_ENEMY_PROACTIVE_RATIO))
780
+
781
+ # Total war: halve reserves
782
+ if self.is_total_war:
783
+ exact_keep = max(1, exact_keep // 2)
784
+ proactive = max(1, proactive // 2)
785
+
786
+ reserve[planet.id] = min(int(planet.ships), max(exact_keep, proactive))
787
+ available[planet.id] = max(0, int(planet.ships) - reserve[planet.id])
788
+
789
+ # Doomed detection
790
+ if not tl["holds_full"] and tl["fall_turn"] is not None:
791
+ if tl["fall_turn"] <= DOOMED_EVAC_TURN_LIMIT and planet.ships >= DOOMED_MIN_SHIPS:
792
+ doomed.add(planet.id)
793
+ # Threatened β€” might be saveable with reinforcement
794
+ if (REINFORCE_ENABLED and planet.production >= REINFORCE_MIN_PRODUCTION
795
+ and self.remaining_steps >= REINFORCE_MIN_FUTURE_TURNS):
796
+ threatened[planet.id] = {
797
+ "fall_turn": tl["fall_turn"],
798
+ }
799
+
800
+ return reserve, available, doomed, threatened
801
+
802
+ def is_static(self, planet_id):
803
+ return is_static_planet(self.planet_by_id[planet_id])
804
+
805
+ def comet_life(self, planet_id):
806
+ return comet_remaining_life(planet_id, self.comets)
807
+
808
+ def plan_shot(self, src_id, target_id, ships):
809
+ ships = int(ships)
810
+ key = (src_id, target_id, ships)
811
+ if key in self.shot_cache:
812
+ return self.shot_cache[key]
813
+ src = self.planet_by_id[src_id]
814
+ target = self.planet_by_id[target_id]
815
+ result = aim_with_prediction(
816
+ src, target, ships, self.initial_by_id, self.ang_vel, self.comets, self.comet_ids
817
+ )
818
+ self.shot_cache[key] = result
819
+ return result
820
+
821
+ def ships_needed_to_capture(self, target_id, arrival_turn, planned_commitments=None):
822
+ """How many ships needed to own target at arrival_turn."""
823
+ planned_commitments = planned_commitments or {}
824
+ cutoff = max(1, int(math.ceil(arrival_turn)))
825
+ arrivals = [x for x in self.arrivals_by_planet.get(target_id, []) if x[0] <= cutoff]
826
+ for x in planned_commitments.get(target_id, []):
827
+ if x[0] <= cutoff:
828
+ arrivals.append(x)
829
+ target = self.planet_by_id[target_id]
830
+ tl = simulate_planet_timeline(target, arrivals, self.player, cutoff)
831
+ owner, ships = state_at_timeline(tl, cutoff)
832
+ if owner == self.player:
833
+ return 0
834
+ return int(math.ceil(ships)) + 1
835
+
836
+ def reaction_times(self, target_id):
837
+ """(my_min_time, enemy_min_time) to reach target."""
838
+ target = self.planet_by_id[target_id]
839
+ my_t = min(
840
+ (travel_time(p.x, p.y, p.radius, target.x, target.y, target.radius, max(1, p.ships))
841
+ for p in self.my_planets),
842
+ default=10**9,
843
+ )
844
+ enemy_t = min(
845
+ (travel_time(p.x, p.y, p.radius, target.x, target.y, target.radius, max(1, p.ships))
846
+ for p in self.enemy_planets),
847
+ default=10**9,
848
+ )
849
+ return my_t, enemy_t
850
+
851
+
852
+ # ═══════════════════════════════════════════
853
+ # STRATEGY
854
+ # ═══════════════════════════════════════════
855
+ def build_modes(world):
856
+ domination = (world.my_total - world.enemy_total) / max(1, world.my_total + world.enemy_total)
857
+ is_behind = domination < BEHIND_DOMINATION
858
+ is_ahead = domination > AHEAD_DOMINATION
859
+ is_dominating = is_ahead or (
860
+ world.max_enemy_strength > 0 and world.my_total > world.max_enemy_strength * 1.25
861
+ )
862
+ is_finishing = (
863
+ domination > FINISHING_DOMINATION
864
+ and world.my_prod > world.enemy_prod * FINISHING_PROD_RATIO
865
+ and world.step > 80
866
+ )
867
+ attack_margin_mult = 1.0
868
+ if is_ahead:
869
+ attack_margin_mult += AHEAD_ATTACK_MARGIN_BONUS
870
+ if is_behind:
871
+ attack_margin_mult -= BEHIND_ATTACK_MARGIN_PENALTY
872
+ if is_finishing:
873
+ attack_margin_mult += FINISHING_ATTACK_MARGIN_BONUS
874
+ return {
875
+ "domination": domination,
876
+ "is_behind": is_behind,
877
+ "is_ahead": is_ahead,
878
+ "is_dominating": is_dominating,
879
+ "is_finishing": is_finishing,
880
+ "attack_margin_mult": attack_margin_mult,
881
+ }
882
+
883
+
884
+ def is_safe_neutral(target, world):
885
+ if target.owner != -1:
886
+ return False
887
+ my_t, enemy_t = world.reaction_times(target.id)
888
+ return my_t <= enemy_t - SAFE_NEUTRAL_MARGIN
889
+
890
+
891
+ def is_contested_neutral(target, world):
892
+ if target.owner != -1:
893
+ return False
894
+ my_t, enemy_t = world.reaction_times(target.id)
895
+ return abs(my_t - enemy_t) <= CONTESTED_NEUTRAL_MARGIN
896
+
897
+
898
+ def opening_filter(target, arrival_turns, needed, src_available, world):
899
+ """Skip low-value rotating neutrals during opening."""
900
+ if not world.is_opening or target.owner != -1:
901
+ return False
902
+ if target.id in world.comet_ids:
903
+ return False
904
+ if world.is_static(target.id):
905
+ return False
906
+ my_t, enemy_t = world.reaction_times(target.id)
907
+ reaction_gap = enemy_t - my_t
908
+ if (target.production >= SAFE_OPENING_PROD_THRESHOLD
909
+ and arrival_turns <= SAFE_OPENING_TURN_LIMIT
910
+ and reaction_gap >= SAFE_NEUTRAL_MARGIN):
911
+ return False
912
+ if world.is_four_player:
913
+ affordable = needed <= max(PARTIAL_SOURCE_MIN_SHIPS, int(src_available * FOUR_PLAYER_ROTATING_SEND_RATIO))
914
+ if affordable and arrival_turns <= FOUR_PLAYER_ROTATING_TURN_LIMIT and reaction_gap >= FOUR_PLAYER_ROTATING_REACTION_GAP:
915
+ return False
916
+ return True
917
+ return arrival_turns > ROTATING_OPENING_MAX_TURNS or target.production <= ROTATING_OPENING_LOW_PROD
918
+
919
+
920
+ def target_value(target, arrival_turns, mission, world, modes):
921
+ """Production snowball scoring: prod Γ— remaining_turns / cost."""
922
+ turns_profit = max(1, world.remaining_steps - arrival_turns)
923
+ if target.id in world.comet_ids:
924
+ life = world.comet_life(target.id)
925
+ turns_profit = max(0, min(turns_profit, life - arrival_turns))
926
+ if turns_profit <= 0:
927
+ return -1.0
928
+
929
+ value = target.production * turns_profit
930
+ value += world.indirect_wealth_map[target.id] * turns_profit * INDIRECT_VALUE_SCALE
931
+
932
+ # Static planet bonus
933
+ if world.is_static(target.id):
934
+ value *= STATIC_NEUTRAL_VALUE_MULT if target.owner == -1 else STATIC_HOSTILE_VALUE_MULT
935
+ else:
936
+ value *= ROTATING_OPENING_VALUE_MULT if world.is_opening else 1.0
937
+
938
+ # Enemy planet bonus (gain + deny)
939
+ if target.owner not in (-1, world.player):
940
+ value *= OPENING_HOSTILE_TARGET_VALUE_MULT if world.is_opening else HOSTILE_TARGET_VALUE_MULT
941
+
942
+ # Neutral safety
943
+ if target.owner == -1:
944
+ if is_safe_neutral(target, world):
945
+ value *= SAFE_NEUTRAL_VALUE_MULT
946
+ elif is_contested_neutral(target, world):
947
+ value *= CONTESTED_NEUTRAL_VALUE_MULT
948
+ if world.is_early:
949
+ value *= EARLY_NEUTRAL_VALUE_MULT
950
+
951
+ # Comet discount
952
+ if target.id in world.comet_ids:
953
+ value *= COMET_VALUE_MULT
954
+
955
+ # Mission-specific multipliers
956
+ if mission == "snipe":
957
+ value *= SNIPE_VALUE_MULT
958
+ elif mission == "swarm":
959
+ value *= SWARM_VALUE_MULT
960
+ elif mission == "reinforce":
961
+ value *= REINFORCE_VALUE_MULT
962
+ elif mission == "crash_exploit":
963
+ value *= CRASH_EXPLOIT_VALUE_MULT
964
+
965
+ # Exposed planet bonus
966
+ if target.id in world.exposed_planet_ids:
967
+ value *= EXPOSED_PLANET_VALUE_MULT
968
+
969
+ # Weakest enemy targeting (4P)
970
+ if world.is_four_player and world._weakest_enemy is not None:
971
+ if target.owner == world._weakest_enemy:
972
+ value *= WEAKEST_ENEMY_VALUE_MULT_4P
973
+
974
+ # Late game bonuses
975
+ if world.is_late:
976
+ value += max(0, target.ships) * LATE_IMMEDIATE_SHIP_VALUE
977
+ if target.owner not in (-1, world.player):
978
+ enemy_strength = world.owner_strength.get(target.owner, 0)
979
+ if enemy_strength <= WEAK_ENEMY_THRESHOLD:
980
+ value += ELIMINATION_BONUS
981
+
982
+ # Finishing mode
983
+ if modes["is_finishing"] and target.owner not in (-1, world.player):
984
+ value *= FINISHING_HOSTILE_VALUE_MULT
985
+ if modes["is_behind"] and target.owner == -1 and not world.is_static(target.id):
986
+ value *= BEHIND_ROTATING_NEUTRAL_VALUE_MULT
987
+ if modes["is_behind"] and target.owner == -1 and is_safe_neutral(target, world):
988
+ value *= 1.08
989
+
990
+ return value
991
+
992
+
993
+ def preferred_send(target, base_needed, arrival_turns, src_available, world, modes):
994
+ """How many ships to send (needed + margin)."""
995
+ send = max(base_needed, int(math.ceil(base_needed * modes["attack_margin_mult"])))
996
+ margin = 0
997
+ if target.owner == -1:
998
+ margin += min(NEUTRAL_MARGIN_CAP, NEUTRAL_MARGIN_BASE + target.production * NEUTRAL_MARGIN_PROD_WEIGHT)
999
+ else:
1000
+ margin += min(HOSTILE_MARGIN_CAP, HOSTILE_MARGIN_BASE + target.production * HOSTILE_MARGIN_PROD_WEIGHT)
1001
+ # Hostile reinforcement prediction
1002
+ if world.enemy_planets:
1003
+ reinforce_est = 0
1004
+ for ep in world.enemy_planets:
1005
+ if ep.owner != target.owner:
1006
+ continue
1007
+ eta = travel_time(ep.x, ep.y, ep.radius, target.x, target.y, target.radius, max(1, ep.ships))
1008
+ if eta <= arrival_turns + HOSTILE_REINFORCE_HORIZON:
1009
+ reinforce_est += int(ep.ships * HOSTILE_REINFORCE_RATIO)
1010
+ margin += min(HOSTILE_REINFORCE_CAP, reinforce_est)
1011
+ if world.is_static(target.id):
1012
+ margin += STATIC_TARGET_MARGIN
1013
+ if is_contested_neutral(target, world):
1014
+ margin += CONTESTED_TARGET_MARGIN
1015
+ if world.is_four_player:
1016
+ margin += FOUR_PLAYER_TARGET_MARGIN
1017
+ if arrival_turns > LONG_TRAVEL_MARGIN_START:
1018
+ margin += min(LONG_TRAVEL_MARGIN_CAP, arrival_turns // LONG_TRAVEL_MARGIN_DIVISOR)
1019
+ if target.id in world.comet_ids:
1020
+ margin = max(0, margin - COMET_MARGIN_RELIEF)
1021
+ if modes["is_finishing"] and target.owner not in (-1, world.player):
1022
+ margin += FINISHING_HOSTILE_SEND_BONUS
1023
+ return min(src_available, send + margin)
1024
+
1025
+
1026
+ def apply_score_modifiers(base_score, target, mission, world):
1027
+ score = base_score
1028
+ if world.is_static(target.id):
1029
+ score *= STATIC_TARGET_SCORE_MULT
1030
+ if world.is_early and target.owner == -1 and world.is_static(target.id):
1031
+ score *= EARLY_STATIC_NEUTRAL_SCORE_MULT
1032
+ if world.is_four_player and target.owner == -1 and not world.is_static(target.id):
1033
+ score *= FOUR_PLAYER_ROTATING_NEUTRAL_SCORE_MULT
1034
+ if len(world.static_neutral_planets) >= DENSE_STATIC_NEUTRAL_COUNT and target.owner == -1 and not world.is_static(target.id):
1035
+ score *= DENSE_ROTATING_NEUTRAL_SCORE_MULT
1036
+ if mission == "snipe":
1037
+ score *= SNIPE_SCORE_MULT
1038
+ elif mission == "swarm":
1039
+ score *= SWARM_SCORE_MULT
1040
+ return score
1041
+
1042
+
1043
+ # ═══════════════════════════════════════════
1044
+ # MISSION BUILDERS
1045
+ # ═══════════════════════════════════════════
1046
+ def build_snipe_missions(src, target, src_available, world, planned, modes):
1047
+ """Detect snipe opportunities: arrive at neutral right after enemy fleet battles."""
1048
+ if target.owner != -1:
1049
+ return None
1050
+ enemy_etas = sorted({
1051
+ int(math.ceil(eta))
1052
+ for eta, owner, ships in world.arrivals_by_planet.get(target.id, [])
1053
+ if owner not in (-1, world.player) and ships > 0
1054
+ })
1055
+ if not enemy_etas:
1056
+ return None
1057
+ probe = min(src_available, max(PARTIAL_SOURCE_MIN_SHIPS, int(target.ships) + 8))
1058
+ rough = world.plan_shot(src.id, target.id, probe)
1059
+ if rough is None:
1060
+ return None
1061
+ for enemy_eta in enemy_etas[:3]:
1062
+ if abs(rough[1] - enemy_eta) > 1:
1063
+ continue
1064
+ sync_turn = max(rough[1], enemy_eta)
1065
+ need = world.ships_needed_to_capture(target.id, sync_turn, planned)
1066
+ if need <= 0 or need > src_available:
1067
+ continue
1068
+ final = world.plan_shot(src.id, target.id, need)
1069
+ if final is None:
1070
+ continue
1071
+ angle, turns, _, _ = final
1072
+ if abs(turns - enemy_eta) > 1:
1073
+ continue
1074
+ value = target_value(target, turns, "snipe", world, modes)
1075
+ if value <= 0:
1076
+ continue
1077
+ score = apply_score_modifiers(value / (need + turns * SNIPE_COST_TURN_WEIGHT + 1.0), target, "snipe", world)
1078
+ option = ShotOption(score=score, src_id=src.id, target_id=target.id, angle=angle, turns=turns, needed=need, send_cap=need, mission="snipe")
1079
+ return Mission(kind="snipe", score=score, target_id=target.id, turns=turns, options=[option])
1080
+ return None
1081
+
1082
+
1083
+ def build_reinforcement_missions(world, planned, modes):
1084
+ """Build missions to save threatened planets."""
1085
+ if not REINFORCE_ENABLED or not world.threatened:
1086
+ return []
1087
+ missions = []
1088
+ for target_id, info in world.threatened.items():
1089
+ target = world.planet_by_id[target_id]
1090
+ fall_turn = info["fall_turn"]
1091
+ if fall_turn is None or fall_turn > REINFORCE_MAX_TRAVEL_TURNS + 5:
1092
+ continue
1093
+ best = None
1094
+ for src in world.my_planets:
1095
+ if src.id == target_id:
1096
+ continue
1097
+ budget = world.available.get(src.id, 0)
1098
+ if budget <= 0:
1099
+ continue
1100
+ source_cap = min(budget, int(src.ships * REINFORCE_MAX_SOURCE_FRACTION))
1101
+ if source_cap <= 0:
1102
+ continue
1103
+ aim = world.plan_shot(src.id, target.id, max(PARTIAL_SOURCE_MIN_SHIPS, source_cap))
1104
+ if aim is None:
1105
+ continue
1106
+ angle, turns, _, _ = aim
1107
+ if turns > REINFORCE_MAX_TRAVEL_TURNS or turns > fall_turn:
1108
+ continue
1109
+ # Estimate needed reinforcement
1110
+ need = world.ships_needed_to_capture(target_id, turns, planned)
1111
+ if need <= 0:
1112
+ need = REINFORCE_SAFETY_MARGIN + 1
1113
+ send = min(source_cap, need + REINFORCE_SAFETY_MARGIN)
1114
+ if send < need:
1115
+ continue
1116
+ value = target_value(target, turns, "reinforce", world, modes)
1117
+ if value <= 0:
1118
+ continue
1119
+ score = value / (send + turns * 0.35 + 1.0)
1120
+ option = ShotOption(score=score, src_id=src.id, target_id=target_id, angle=angle, turns=turns, needed=need, send_cap=send, mission="reinforce")
1121
+ mission = Mission(kind="reinforce", score=score, target_id=target_id, turns=turns, options=[option])
1122
+ if best is None or mission.score > best.score:
1123
+ best = mission
1124
+ if best is not None:
1125
+ missions.append(best)
1126
+ return missions
1127
+
1128
+
1129
+ def build_crash_exploit_missions(world, planned, modes):
1130
+ """In 4P, arrive after two enemies crash into each other."""
1131
+ if not world.enemy_crashes:
1132
+ return []
1133
+ missions = []
1134
+ for crash in world.enemy_crashes:
1135
+ target_id = crash["target_id"]
1136
+ target = world.planet_by_id[target_id]
1137
+ if target.owner == world.player:
1138
+ continue
1139
+ desired_arrival = crash["crash_turn"] + CRASH_EXPLOIT_POST_CRASH_DELAY
1140
+ best = None
1141
+ for src in world.my_planets:
1142
+ probe = min(max(PARTIAL_SOURCE_MIN_SHIPS, 12), int(src.ships))
1143
+ if probe <= 0:
1144
+ continue
1145
+ aim = world.plan_shot(src.id, target_id, probe)
1146
+ if aim is None:
1147
+ continue
1148
+ _, turns, _, _ = aim
1149
+ if abs(turns - desired_arrival) > 2:
1150
+ continue
1151
+ need = world.ships_needed_to_capture(target_id, turns, planned)
1152
+ if need <= 0 or need > int(src.ships):
1153
+ continue
1154
+ final = world.plan_shot(src.id, target_id, need)
1155
+ if final is None:
1156
+ continue
1157
+ angle, turns, _, _ = final
1158
+ value = target_value(target, turns, "crash_exploit", world, modes)
1159
+ if value <= 0:
1160
+ continue
1161
+ score = value / (need + turns * SNIPE_COST_TURN_WEIGHT + 1.0)
1162
+ option = ShotOption(score=score, src_id=src.id, target_id=target_id, angle=angle, turns=turns, needed=need, send_cap=need, mission="crash_exploit")
1163
+ mission = Mission(kind="crash_exploit", score=score, target_id=target_id, turns=turns, options=[option])
1164
+ if best is None or mission.score > best.score:
1165
+ best = mission
1166
+ if best is not None:
1167
+ missions.append(best)
1168
+ return missions
1169
+
1170
+
1171
+ # ═══════════════════════════════════════════
1172
+ # PLAN MOVES (main orchestrator)
1173
+ # ═══════════════════════════════════════════
1174
+ def plan_moves(world, deadline=None):
1175
+ modes = build_modes(world)
1176
+ planned_commitments = defaultdict(list)
1177
+ source_options_by_target = defaultdict(list)
1178
+ missions = []
1179
+ moves = []
1180
+ spent_total = defaultdict(int)
1181
+
1182
+ def source_attack_left(source_id):
1183
+ return max(0, world.available.get(source_id, 0) - spent_total[source_id])
1184
+
1185
+ def source_inventory_left(source_id):
1186
+ return max(0, int(world.planet_by_id[source_id].ships) - spent_total[source_id])
1187
+
1188
+ def append_move(src_id, angle, ships):
1189
+ send = min(int(ships), source_inventory_left(src_id))
1190
+ if send < 1:
1191
+ return 0
1192
+ moves.append([src_id, float(angle), int(send)])
1193
+ spent_total[src_id] += send
1194
+ return send
1195
+
1196
+ def time_ok():
1197
+ return deadline is None or time.perf_counter() < deadline
1198
+
1199
+ # ── Phase 1: Reinforcement missions ──
1200
+ reinforce_missions = build_reinforcement_missions(world, planned_commitments, modes)
1201
+ missions.extend(reinforce_missions)
1202
+
1203
+ # ── Phase 2: Crash exploit missions (4P) ──
1204
+ crash_missions = build_crash_exploit_missions(world, planned_commitments, modes)
1205
+ missions.extend(crash_missions)
1206
+
1207
+ # ── Phase 3: Build capture/snipe options for all (src, target) pairs ──
1208
+ for src in world.my_planets:
1209
+ if not time_ok():
1210
+ break
1211
+ src_avail = source_attack_left(src.id)
1212
+ if src_avail <= 0:
1213
+ continue
1214
+
1215
+ for target in world.planets:
1216
+ if target.id == src.id or target.owner == world.player:
1217
+ continue
1218
+
1219
+ # Quick feasibility check
1220
+ rough_ships = max(1, min(src_avail, max(PARTIAL_SOURCE_MIN_SHIPS, int(target.ships) + 1)))
1221
+ rough = world.plan_shot(src.id, target.id, rough_ships)
1222
+ if rough is None:
1223
+ continue
1224
+ rough_turns = rough[1]
1225
+
1226
+ # Time validity
1227
+ if world.is_very_late and rough_turns > world.remaining_steps - 3:
1228
+ continue
1229
+ if target.id in world.comet_ids:
1230
+ life = world.comet_life(target.id)
1231
+ if rough_turns >= life or rough_turns > COMET_MAX_CHASE_TURNS:
1232
+ continue
1233
+
1234
+ rough_needed = world.ships_needed_to_capture(target.id, rough_turns, planned_commitments)
1235
+ if rough_needed <= 0:
1236
+ continue
1237
+ if opening_filter(target, rough_turns, rough_needed, src_avail, world):
1238
+ continue
1239
+
1240
+ # Precise aiming with preferred send
1241
+ send_guess = preferred_send(target, rough_needed, rough_turns, src_avail, world, modes)
1242
+ aim = world.plan_shot(src.id, target.id, max(1, send_guess))
1243
+ if aim is None:
1244
+ continue
1245
+ angle, turns, _, _ = aim
1246
+
1247
+ if world.is_very_late and turns > world.remaining_steps - 3:
1248
+ continue
1249
+ if target.id in world.comet_ids:
1250
+ life = world.comet_life(target.id)
1251
+ if turns >= life or turns > COMET_MAX_CHASE_TURNS:
1252
+ continue
1253
+
1254
+ needed = world.ships_needed_to_capture(target.id, turns, planned_commitments)
1255
+ if needed <= 0:
1256
+ continue
1257
+ if opening_filter(target, turns, needed, src_avail, world):
1258
+ continue
1259
+
1260
+ send_cap = min(src_avail, preferred_send(target, needed, turns, src_avail, world, modes))
1261
+ if send_cap < 1:
1262
+ continue
1263
+ if send_cap < needed and send_cap < PARTIAL_SOURCE_MIN_SHIPS:
1264
+ continue
1265
+
1266
+ value = target_value(target, turns, "capture", world, modes)
1267
+ if value <= 0:
1268
+ continue
1269
+
1270
+ expected_send = max(needed, min(send_cap, preferred_send(target, needed, turns, send_cap, world, modes)))
1271
+ score = apply_score_modifiers(
1272
+ value / (expected_send + turns * ATTACK_COST_TURN_WEIGHT + 1.0),
1273
+ target, "capture", world
1274
+ )
1275
+
1276
+ option = ShotOption(
1277
+ score=score, src_id=src.id, target_id=target.id,
1278
+ angle=angle, turns=turns, needed=needed, send_cap=send_cap, mission="capture"
1279
+ )
1280
+ source_options_by_target[target.id].append(option)
1281
+
1282
+ if send_cap >= needed:
1283
+ missions.append(Mission(kind="single", score=score, target_id=target.id, turns=turns, options=[option]))
1284
+
1285
+ # Snipe check
1286
+ snipe = build_snipe_missions(src, target, src_avail, world, planned_commitments, modes)
1287
+ if snipe is not None:
1288
+ missions.append(snipe)
1289
+
1290
+ # ── Phase 4: Multi-source swarm assembly ──
1291
+ for target_id, options in source_options_by_target.items():
1292
+ if len(options) < 2:
1293
+ continue
1294
+ target = world.planet_by_id[target_id]
1295
+ top_opts = sorted(options, key=lambda x: -x.score)[:MULTI_SOURCE_TOP_K]
1296
+
1297
+ hostile = target.owner not in (-1, world.player)
1298
+ eta_tol = HOSTILE_SWARM_ETA_TOLERANCE if hostile else MULTI_SOURCE_ETA_TOLERANCE
1299
+
1300
+ # 2-source swarms
1301
+ for i in range(len(top_opts)):
1302
+ for j in range(i + 1, len(top_opts)):
1303
+ a, b = top_opts[i], top_opts[j]
1304
+ if a.src_id == b.src_id:
1305
+ continue
1306
+ if abs(a.turns - b.turns) > eta_tol:
1307
+ continue
1308
+ joint_turn = max(a.turns, b.turns)
1309
+ need = world.ships_needed_to_capture(target_id, joint_turn, planned_commitments)
1310
+ if need <= 0:
1311
+ continue
1312
+ if a.send_cap >= need or b.send_cap >= need:
1313
+ continue # Single source can handle it
1314
+ if a.send_cap + b.send_cap < need:
1315
+ continue
1316
+ value = target_value(target, joint_turn, "swarm", world, modes)
1317
+ if value <= 0:
1318
+ continue
1319
+ score = apply_score_modifiers(
1320
+ value / (need + joint_turn * ATTACK_COST_TURN_WEIGHT + 1.0),
1321
+ target, "swarm", world
1322
+ ) * MULTI_SOURCE_PLAN_PENALTY
1323
+ missions.append(Mission(kind="swarm", score=score, target_id=target_id, turns=joint_turn, options=[a, b]))
1324
+
1325
+ # 3-source swarms
1326
+ if THREE_SOURCE_SWARM_ENABLED and len(top_opts) >= 3 and target.ships >= THREE_SOURCE_MIN_TARGET_SHIPS:
1327
+ for i in range(len(top_opts)):
1328
+ for j in range(i + 1, len(top_opts)):
1329
+ for k in range(j + 1, len(top_opts)):
1330
+ a, b, c = top_opts[i], top_opts[j], top_opts[k]
1331
+ if len({a.src_id, b.src_id, c.src_id}) < 3:
1332
+ continue
1333
+ max_t = max(a.turns, b.turns, c.turns)
1334
+ min_t = min(a.turns, b.turns, c.turns)
1335
+ if max_t - min_t > THREE_SOURCE_ETA_TOLERANCE + 1:
1336
+ continue
1337
+ total_cap = a.send_cap + b.send_cap + c.send_cap
1338
+ need = world.ships_needed_to_capture(target_id, max_t, planned_commitments)
1339
+ if need <= 0 or total_cap < need:
1340
+ continue
1341
+ value = target_value(target, max_t, "swarm", world, modes)
1342
+ if value <= 0:
1343
+ continue
1344
+ score = apply_score_modifiers(
1345
+ value / (need + max_t * ATTACK_COST_TURN_WEIGHT + 1.0),
1346
+ target, "swarm", world
1347
+ ) * THREE_SOURCE_PLAN_PENALTY
1348
+ missions.append(Mission(kind="swarm", score=score, target_id=target_id, turns=max_t, options=[a, b, c]))
1349
+
1350
+ # ── Phase 5: Dispatch missions by score ──
1351
+ missions.sort(key=lambda m: m.score, reverse=True)
1352
+ targeted = set()
1353
+
1354
+ for mission in missions:
1355
+ if not time_ok():
1356
+ break
1357
+ tid = mission.target_id
1358
+
1359
+ # For single-source missions: skip if target already taken
1360
+ if mission.kind in ("single", "snipe", "crash_exploit") and tid in targeted:
1361
+ continue
1362
+
1363
+ # Check all sources still have budget
1364
+ can_execute = True
1365
+ for opt in mission.options:
1366
+ avail = source_attack_left(opt.src_id)
1367
+ if mission.kind == "reinforce":
1368
+ avail = source_inventory_left(opt.src_id)
1369
+ if avail < opt.needed:
1370
+ can_execute = False
1371
+ break
1372
+ if not can_execute:
1373
+ continue
1374
+
1375
+ # Execute
1376
+ for opt in mission.options:
1377
+ avail = source_attack_left(opt.src_id)
1378
+ if mission.kind == "reinforce":
1379
+ avail = source_inventory_left(opt.src_id)
1380
+ send = min(avail, opt.send_cap)
1381
+ send = max(send, opt.needed)
1382
+ send = min(send, avail)
1383
+ if send < 1:
1384
+ continue
1385
+ # Re-aim with exact send count
1386
+ aim = world.plan_shot(opt.src_id, opt.target_id, send)
1387
+ if aim is None:
1388
+ append_move(opt.src_id, opt.angle, send)
1389
+ else:
1390
+ append_move(opt.src_id, aim[0], send)
1391
+ # Track commitment
1392
+ planned_commitments[opt.target_id].append((opt.turns, world.player, send))
1393
+
1394
+ targeted.add(tid)
1395
+
1396
+ # ── Phase 6: Doomed planet evacuation ──
1397
+ for planet_id in world.doomed:
1398
+ planet = world.planet_by_id[planet_id]
1399
+ evac_ships = source_inventory_left(planet_id)
1400
+ if evac_ships < DOOMED_MIN_SHIPS:
1401
+ continue
1402
+ # Find best nearby ally to evacuate to
1403
+ best_dest = None
1404
+ best_score = -1
1405
+ for ally in world.my_planets:
1406
+ if ally.id == planet_id:
1407
+ continue
1408
+ if ally.id in world.doomed:
1409
+ continue
1410
+ aim = world.plan_shot(planet_id, ally.id, evac_ships)
1411
+ if aim is None:
1412
+ continue
1413
+ _, turns, _, _ = aim
1414
+ if turns > DOOMED_EVAC_TURN_LIMIT:
1415
+ continue
1416
+ score = ally.production * 10 + ally.ships - turns * 2
1417
+ if score > best_score:
1418
+ best_score = score
1419
+ best_dest = (ally.id, aim[0], turns)
1420
+ if best_dest is not None:
1421
+ dest_id, angle, turns = best_dest
1422
+ send = max(1, evac_ships - 1) # Leave 1 to slow enemy capture
1423
+ append_move(planet_id, angle, send)
1424
+
1425
+ # ── Phase 7: Rear forwarding β€” move surplus from back-line to front ──
1426
+ if not world.is_opening and world.enemy_planets:
1427
+ front_center_x = sum(p.x for p in world.enemy_planets) / len(world.enemy_planets)
1428
+ front_center_y = sum(p.y for p in world.enemy_planets) / len(world.enemy_planets)
1429
+ my_center_x = sum(p.x for p in world.my_planets) / len(world.my_planets)
1430
+ my_center_y = sum(p.y for p in world.my_planets) / len(world.my_planets)
1431
+
1432
+ for src in world.my_planets:
1433
+ surplus = source_attack_left(src.id)
1434
+ if surplus < REAR_SOURCE_MIN_SHIPS:
1435
+ continue
1436
+ # Is this a rear planet? (farther from enemy than our center)
1437
+ src_to_enemy = dist(src.x, src.y, front_center_x, front_center_y)
1438
+ center_to_enemy = dist(my_center_x, my_center_y, front_center_x, front_center_y)
1439
+ if src_to_enemy < center_to_enemy * REAR_DISTANCE_RATIO:
1440
+ continue
1441
+ # Find best frontline destination
1442
+ best_front = None
1443
+ best_front_score = -1
1444
+ for front in world.my_planets:
1445
+ if front.id == src.id:
1446
+ continue
1447
+ front_to_enemy = dist(front.x, front.y, front_center_x, front_center_y)
1448
+ if front_to_enemy >= src_to_enemy:
1449
+ continue
1450
+ aim = world.plan_shot(src.id, front.id, surplus)
1451
+ if aim is None:
1452
+ continue
1453
+ _, turns, _, _ = aim
1454
+ if turns > REAR_MAX_TRAVEL_TURNS:
1455
+ continue
1456
+ score = front.production * 5 - turns * 2 - front_to_enemy
1457
+ if score > best_front_score:
1458
+ best_front_score = score
1459
+ best_front = (front.id, aim[0], turns)
1460
+ if best_front is not None:
1461
+ ratio = REAR_SEND_RATIO_FOUR_PLAYER if world.is_four_player else REAR_SEND_RATIO_TWO_PLAYER
1462
+ send = max(REAR_SEND_MIN_SHIPS, int(surplus * ratio))
1463
+ send = min(send, surplus)
1464
+ append_move(src.id, best_front[1], send)
1465
+
1466
+ # ── Phase 8: Endgame consolidation ──
1467
+ if world.remaining_steps < 40 and len(world.my_planets) >= 3:
1468
+ best_prod = max(world.my_planets, key=lambda p: p.production)
1469
+ for src in world.my_planets:
1470
+ if src.id == best_prod.id:
1471
+ continue
1472
+ surplus = source_inventory_left(src.id)
1473
+ if surplus < 15:
1474
+ continue
1475
+ aim = world.plan_shot(src.id, best_prod.id, surplus)
1476
+ if aim is None:
1477
+ continue
1478
+ _, turns, _, _ = aim
1479
+ turns_left = world.remaining_steps - turns
1480
+ if turns_left < 5:
1481
+ continue
1482
+ prod_diff = best_prod.production - src.production
1483
+ if prod_diff <= 0:
1484
+ continue
1485
+ send = min(surplus // 2, surplus)
1486
+ if send < 10:
1487
+ continue
1488
+ append_move(src.id, aim[0], send)
1489
+
1490
+ return moves
1491
+
1492
+
1493
+ # ═══════════════════════════════════════════
1494
+ # AGENT ENTRY POINT
1495
+ # ═══════════════════════════════════════════
1496
+ _agent_step = 0
1497
+
1498
+
1499
+ def agent(obs, config=None):
1500
+ global _agent_step
1501
+ _agent_step += 1
1502
+ start_time = time.perf_counter()
1503
+
1504
+ g = lambda k, d: obs.get(k, d) if isinstance(obs, dict) else getattr(obs, k, d)
1505
+
1506
+ player = g("player", 0)
1507
+ step = g("step", 0)
1508
+ ang_vel = g("angular_velocity", 0.035)
1509
+ comet_ids = set(g("comet_planet_ids", []))
1510
+ comets = g("comets", [])
1511
+
1512
+ raw_planets = g("planets", [])
1513
+ raw_fleets = g("fleets", [])
1514
+ raw_init = g("initial_planets", [])
1515
+
1516
+ planets = [Planet(*p) for p in raw_planets]
1517
+ fleets = [Fleet(*f) for f in raw_fleets]
1518
+ initial_by_id = {Planet(*p).id: Planet(*p) for p in raw_init}
1519
+
1520
+ if not any(p.owner == player for p in planets):
1521
+ return []
1522
+
1523
+ act_timeout = 1.0
1524
+ if config is not None:
1525
+ act_timeout = config.get("actTimeout", 1.0) if isinstance(config, dict) else getattr(config, "actTimeout", 1.0)
1526
+ deadline = start_time + min(SOFT_ACT_DEADLINE, max(0.55, act_timeout * 0.82))
1527
+
1528
+ world = WorldModel(player, step, planets, fleets, initial_by_id, ang_vel, comets, comet_ids)
1529
+ return plan_moves(world, deadline=deadline)