@@ -234,13 +234,15 @@ def step(self, action: int) -> Tuple[np.ndarray, float, bool, bool, dict]:
234234 self .factory .tick ()
235235 self ._accrue_compute (dt )
236236
237- # ── 5. Compute reward ──────────────────────────────────────────────
238- reward = self ._reward_calc .compute (self , action_valid , harvested )
239-
240- # ── 6. Termination / truncation ────────────────────────────────────
237+ # ── 5. Termination / truncation (evaluated before reward so terminal bonus fires)
241238 terminated = self .money < 0
242239 truncated = self ._step >= cfg .max_steps
243240
241+ # ── 6. Compute reward ──────────────────────────────────────────────
242+ reward = self ._reward_calc .compute (
243+ self , action_valid , harvested , terminated = (terminated or truncated )
244+ )
245+
244246 obs = self ._encode_obs ()
245247 info = {
246248 "step" : self ._step ,
@@ -284,13 +286,14 @@ def _execute_action(self, action: Action) -> Tuple[bool, float]:
284286 mkt = self ._market_at (* mkt_pos )
285287 if mkt is None or u .free_capacity < 1 :
286288 return False , 0.0
287- # Buy the product with highest profit margin (price - cost) we can afford
289+ # Buy at market price; select product with most upside for cross-market resale
288290 best_pid , best_cost = self ._best_buyable (mkt )
289291 if best_pid is None :
290292 return False , 0.0
291- cost = best_cost
292- self .money -= cost
293+ self .money -= best_cost
293294 u .add_product (best_pid , 1.0 )
295+ # Record which market this product came from to enforce cross-market rule
296+ u .prod_origin [best_pid ] = mkt .id
294297 u .state = "loading"
295298 u .busy_ticks = max (1 , int (0.25 / cfg .time_step ))
296299 u .busy_action = "buy_done"
@@ -307,9 +310,14 @@ def _execute_action(self, action: Action) -> Tuple[bool, float]:
307310 qty = u .prod_inv .get (pid , 0.0 )
308311 if qty <= 0 :
309312 return False , 0.0
313+ # Strict same-location arbitrage prevention: block selling at the market
314+ # where this product was purchased (requires cross-market movement to profit)
315+ if u .prod_origin .get (pid ) == mkt .id :
316+ return False , 0.0
310317 mult = self ._price_multiplier ()
311318 revenue = mkt .get_price (pid , self .time , mult ) * qty
312319 u .prod_inv [pid ] = 0.0
320+ u .prod_origin .pop (pid , None )
313321 self .money += revenue
314322 self .score += revenue * cfg .score_factor
315323 u .state = "selling"
@@ -354,9 +362,17 @@ def _execute_action(self, action: Action) -> Tuple[bool, float]:
354362 if action == Action .LOAD :
355363 if not board .at_factory (u .x , u .y ):
356364 return False , 0.0
365+ # Snapshot inventory before loading to detect which slots were empty
366+ pre_inv = {pid : u .prod_inv .get (pid , 0.0 ) for pid in PRODUCT_DEFS }
357367 loaded = self .factory .load_products (u )
358368 if loaded <= 0 :
359369 return False , 0.0
370+ # Factory-loaded products get None origin (sellable anywhere) only when
371+ # the slot was empty beforehand; if there are existing market-tainted units
372+ # of that type, the taint persists to prevent mixing-based bypass.
373+ for pid in PRODUCT_DEFS :
374+ if u .prod_inv .get (pid , 0.0 ) > pre_inv [pid ] and pre_inv [pid ] == 0 :
375+ u .prod_origin .pop (pid , None ) # None / absent means factory origin
360376 u .state = "loading"
361377 u .busy_ticks = max (1 , int (0.25 / cfg .time_step ))
362378 u .busy_action = "load_done"
@@ -427,19 +443,24 @@ def _market_at(self, x: int, y: int) -> Optional[Market]:
427443 return None
428444
429445 def _best_buyable (self , mkt : Market ) -> Tuple [Optional [int ], float ]:
430- """Return (pid, cost) of product with highest profit (price - cost) we can afford."""
431- best_pid , best_cost = None , None
432- best_profit = - float ("inf" )
433- mult = self ._price_multiplier ()
446+ """Return (pid, buy_price) of affordable product with most upside (hi - current_price).
447+
448+ Buying costs the current market price (not manufacturing cost), so same-location
449+ buy-then-sell yields no profit. Cross-market arbitrage remains viable.
450+ """
451+ best_pid , best_price = None , None
452+ best_upside = - float ("inf" )
434453 for pid , pdef in PRODUCT_DEFS .items ():
435- cost = max ( 0 , pdef [ "cost" ] + self .factory . cost_delta )
436- if self .money < cost :
454+ price = mkt . get_price ( pid , self .time , 1.0 ) # buy at market price, no marketing mult
455+ if self .money < price :
437456 continue
438- price = mkt .get_price (pid , self .time , mult )
439- profit = price - cost
440- if profit > best_profit :
441- best_profit , best_cost , best_pid = profit , cost , pid
442- return best_pid , best_cost
457+ hi = pdef ["val_range" ][1 ]
458+ upside = hi - price
459+ if upside > best_upside :
460+ best_upside = upside
461+ best_pid = pid
462+ best_price = price
463+ return best_pid , best_price
443464
444465 def _price_multiplier (self ) -> float :
445466 return self .factory .price_multiplier
0 commit comments