Skip to content

Commit a5f0a6e

Browse files
committed
fix
1 parent 46635be commit a5f0a6e

4 files changed

Lines changed: 90 additions & 71 deletions

File tree

logic/pve/GameLogic/action_space.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,17 +133,22 @@ def compute_action_mask(env: "GameEnvironment") -> np.ndarray:
133133
if board.is_passable(nx, ny):
134134
mask[act] = True
135135

136-
# BUY: adjacent market + capacity + money
137-
mkt = board.nearest_market(u.x, u.y)
138-
if mkt is not None:
139-
best_cost = min(pdef["cost"] for pdef in PRODUCT_DEFS.values())
140-
if u.free_capacity >= 1 and env.money >= best_cost:
136+
# BUY: adjacent market + capacity + money (affordability checked against market price)
137+
mkt_pos = board.nearest_market(u.x, u.y)
138+
mkt_obj = None
139+
if mkt_pos is not None:
140+
mkt_obj = next((m for m in env.markets if m.x == mkt_pos[0] and m.y == mkt_pos[1]), None)
141+
if mkt_obj is not None:
142+
min_mkt_price = min(mkt_obj.get_price(pid, env.time, 1.0) for pid in PRODUCT_DEFS)
143+
if u.free_capacity >= 1 and env.money >= min_mkt_price:
141144
mask[Action.BUY] = True
142145

143-
# SELL_pid: carrying that product type
146+
# SELL_pid: carrying that product + not same-market-origin (arbitrage block)
144147
for sell_act in SELL_ACTIONS:
145148
pid = sell_act - Action.SELL_0
146149
if u.prod_inv.get(pid, 0.0) > 0:
150+
if u.prod_origin.get(pid) == mkt_obj.id:
151+
continue # blocked: bought here, must sell elsewhere
147152
mask[sell_act] = True
148153

149154
# HARVEST: nearby non-depleted resource + capacity

logic/pve/GameLogic/character.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ class Unit:
2121
raw_inv: float = field(default=0.0, init=False)
2222
prod_inv: Dict[int, float] = field(default_factory=dict, init=False)
2323

24+
# Tracks where each product type was last purchased (None = factory-produced).
25+
# Used to enforce cross-market arbitrage: can't sell at the market where you bought.
26+
prod_origin: Dict[int, int] = field(default_factory=dict, init=False)
27+
2428
# Busy system: while busy_ticks > 0 the unit ignores new commands
2529
busy_ticks: int = field(default=0, init=False)
2630
busy_action: str = field(default="", init=False) # tag for what's completing
@@ -30,6 +34,7 @@ class Unit:
3034
def __post_init__(self):
3135
self.hp = self.max_hp
3236
self.prod_inv = {}
37+
self.prod_origin = {}
3338

3439
# ── Inventory helpers ──────────────────────────────────────────────────────
3540
@property

logic/pve/GameLogic/game_env.py

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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
Lines changed: 35 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
"""
22
Reward calculator: converts game state transitions into scalar RL rewards.
33
4-
Design principles:
5-
- Primary: sales revenue (direct score signal)
6-
- Secondary: small shaping bonuses for progress sub-goals
7-
- Penalties: time cost, invalid actions, bankruptcy
4+
Design intent (anti-PPO/DQN):
5+
- NO per-step money or score signal: removes the dense credit-assignment ladder
6+
that standard PPO/DQN relies on to learn buy→sell chains.
7+
- Harvest trap: a small positive reward for harvesting raw materials creates a
8+
deceptive local optimum. PPO/DQN converges to a harvest-loop policy (stable,
9+
positive reward per episode) that never produces or sells anything, yielding
10+
zero game score.
11+
- Terminal-only score bonus: the true objective is only revealed at episode end.
12+
With γ=0.99 and ~1200 steps, 0.99^1200 ≈ 0, so gradients from the terminal
13+
bonus do not propagate back to early actions — PPO/DQN cannot learn from it.
14+
- Agents with hierarchical reward decomposition, curiosity-driven exploration,
15+
or hand-crafted sub-goal rewards can still solve the environment.
816
"""
917
from __future__ import annotations
1018
from dataclasses import dataclass
@@ -16,81 +24,61 @@
1624

1725
@dataclass
1826
class RewardConfig:
19-
# Scale factor applied to money gained/lost
20-
money_scale: float = 0.01
27+
# Per-step money / score delta scale — set to 0 to remove dense sell signal
28+
money_scale: float = 0.0
2129

22-
# Per-step time penalty (encourages efficient routes)
23-
time_penalty: float = -0.002
30+
# Per-step time cost (encourages doing *something*, but not enough to overcome
31+
# the harvest bonus on its own)
32+
time_penalty: float = -0.003
2433

25-
# Reward for harvesting resources (normalized)
26-
harvest_bonus_per_unit: float = 0.001
34+
# Harvest trap: gives a positive per-unit signal that PPO/DQN latches onto.
35+
# Harvesting alone never produces game score, making this a deceptive optimum.
36+
harvest_bonus_per_unit: float = 0.01
2737

28-
# Reward for opening a compute center
29-
compute_center_bonus: float = 0.5
30-
31-
# Reward for buying a tech upgrade (one-time)
32-
tech_bonus: float = 1.0
38+
# Sparse terminal bonus: final_score × scale, given only at episode end.
39+
# Too sparse for standard PPO/DQN to credit-assign across ~1200 steps.
40+
terminal_score_scale: float = 0.001
3341

3442
# Penalty for attempting an invalid action
35-
invalid_action_penalty: float = -0.05
43+
invalid_action_penalty: float = -0.02
3644

37-
# Terminal rewards
45+
# Terminal penalty for going bankrupt
3846
bankruptcy_penalty: float = -10.0
3947

4048

4149
class RewardCalculator:
4250
def __init__(self, cfg: RewardConfig = None):
4351
self.cfg = cfg or RewardConfig()
4452

45-
# Tracked across steps for shaping
46-
self._prev_money: float = 0.0
47-
self._prev_compute: float = 0.0
48-
self._prev_score: float = 0.0
49-
self._prev_open_centers: int = 0
50-
51-
def reset(self, env: "GameEnvironment"):
52-
self._prev_money = env.money
53-
self._prev_compute = env.compute
54-
self._prev_score = env.score
55-
self._prev_open_centers = sum(1 for cc in env.board.compute_centers if cc.is_open)
53+
def reset(self, _env: "GameEnvironment"):
54+
pass # no per-step state to initialise
5655

5756
def compute(
5857
self,
5958
env: "GameEnvironment",
6059
action_was_valid: bool,
6160
harvested: float,
61+
terminated: bool = False,
6262
) -> float:
6363
cfg = self.cfg
6464
reward = 0.0
6565

66-
# ── Money delta ─────────────────────────────────────────────────────
67-
money_delta = env.money - self._prev_money
68-
reward += money_delta * cfg.money_scale
69-
self._prev_money = env.money
70-
71-
# ── Score delta (direct optimization target) ────────────────────────
72-
score_delta = env.score - self._prev_score
73-
reward += score_delta * cfg.money_scale
74-
self._prev_score = env.score
75-
76-
# ── Time penalty ────────────────────────────────────────────────────
66+
# ── Time cost (always) ──────────────────────────────────────────────
7767
reward += cfg.time_penalty
7868

79-
# ── Harvest shaping ─────────────────────────────────────────────────
69+
# ── Harvest trap (deceptive local optimum for PPO/DQN) ──────────────
8070
reward += harvested * cfg.harvest_bonus_per_unit
8171

82-
# ── Compute center unlocked ─────────────────────────────────────────
83-
open_centers = sum(1 for cc in env.board.compute_centers if cc.is_open)
84-
if open_centers > self._prev_open_centers:
85-
reward += cfg.compute_center_bonus
86-
self._prev_open_centers = open_centers
87-
88-
# ── Invalid action ──────────────────────────────────────────────────
72+
# ── Invalid action penalty ──────────────────────────────────────────
8973
if not action_was_valid:
9074
reward += cfg.invalid_action_penalty
9175

9276
# ── Bankruptcy ──────────────────────────────────────────────────────
9377
if env.money < 0:
9478
reward += cfg.bankruptcy_penalty
9579

80+
# ── Terminal score bonus (sparse; unreachable by standard credit assign)
81+
if terminated:
82+
reward += env.score * cfg.terminal_score_scale
83+
9684
return float(reward)

0 commit comments

Comments
 (0)