# Copyright (c) Meta Platforms, Inc. and affiliates. # All rights reserved. # # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. """Supergames environment implementation.""" import copy from typing import List from uuid import uuid4 from openenv.core.env_server.interfaces import Environment from openenv.core.env_server.types import State try: from ..models import SupergamesAction, SupergamesObservation, WorkItem except ImportError: import sys, os sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) from models import SupergamesAction, SupergamesObservation, WorkItem try: from ..tasks import TASKS from ..simulator import simulateSprint except ImportError: from tasks import TASKS from simulator import simulateSprint class SupergamesEnvironment(Environment): """Environment for staffing and sprint allocation across Supergames tasks.""" SUPPORTS_CONCURRENT_SESSIONS: bool = True MIN_REWARD: float = -100.0 MAX_REWARD: float = 100.0 def __init__(self): self.stateData = State(episode_id=str(uuid4()), step_count=0) # task data self.taskId = 1 self.games = [] self.workQueue = [] self.staffPool = None self.totalSteps = 0 self.goal = "" # tracking self.completedItems: List[WorkItem] = [] self.cumulativeRevenue = 0.0 self.crisisResolved = False self.done = True self.initialWorkQueue = [] self.initialStaffPool = None self.estimatedOptimalRevenue = 1.0 self.pendingCrisisItems = [] def buildObservation(self, reward: float | None = None) -> SupergamesObservation: return SupergamesObservation( taskID=self.taskId, currentStep=self.stateData.step_count, totalSteps=self.totalSteps, games=copy.deepcopy(self.games), workQueue=copy.deepcopy(self.workQueue), staffPool=copy.deepcopy(self.staffPool), crisis=any(item.crisis for item in self.workQueue), goal=self.goal, done=self.done, reward=reward, metadata={ "completedItems": list(self.completedItems), "cumulativeRevenue": round(self.cumulativeRevenue, 2), }, ) @staticmethod def _formatRevenue(amount: float) -> str: if abs(amount) >= 1_000_000: return f"${amount / 1_000_000:.2f}M" if abs(amount) >= 1_000: return f"${amount / 1_000:.0f}k" return f"${amount:.2f}" def _buildSprintSummary( self, sprintRevenue: float, completedItems: List[WorkItem], ) -> str: unresolvedCriticalBugs = sum( 1 for item in self.workQueue if item.workType.value == "bug" and int(item.severity) >= 4 ) itemLabel = "item" if len(completedItems) == 1 else "items" bugLabel = ( "critical/blocker bug" if unresolvedCriticalBugs == 1 else "critical/blocker bugs" ) summary = ( f"Sprint {self.stateData.step_count}: " f"earned {self._formatRevenue(sprintRevenue)}, " f"completed {len(completedItems)} {itemLabel}, " f"{unresolvedCriticalBugs} {bugLabel} unresolved" ) maxChurnMult = max((game.churnMult for game in self.games), default=1.0) if maxChurnMult > 1.0: summary += f" (churn x{maxChurnMult:.2f})" return summary def _clampReward(self, reward: float) -> float: return round(min(self.MAX_REWARD, max(self.MIN_REWARD, reward)), 2) @staticmethod def _delayImpactPoints(item: WorkItem) -> float: if item.impactDelay == 0: return 45.0 if item.impactDelay == 1: return 25.0 return 10.0 @staticmethod def _completedItemPoints(item: WorkItem) -> float: severityPoints = int(item.severity) * 3.0 revenuePoints = min(20.0, item.revenueImpact / 20.0) churnPoints = item.churnReduction * 40.0 return ( SupergamesEnvironment._delayImpactPoints(item) + severityPoints + revenuePoints + churnPoints ) def computeReward( self, sprintRevenue: float, completedItems: List[WorkItem] | None = None, message: str = "ok", ) -> float: completedItems = completedItems or [] if message.startswith("Overallocation"): return -70.0 if message.startswith("Unknown work item"): return -60.0 reward = min(35.0, sprintRevenue / 100_000.0) reward += sum(self._completedItemPoints(item) for item in completedItems) unresolvedPenalty = 0.0 for item in self.workQueue: if item.workType.value != "bug": continue if int(item.severity) == 5: unresolvedPenalty += 18.0 elif int(item.severity) == 4: unresolvedPenalty += 10.0 reward -= unresolvedPenalty churnPenalty = sum(max(0.0, game.churnMult - 1.0) * 40.0 for game in self.games) reward -= churnPenalty if self.done: if not any(item.workType.value == "bug" and int(item.severity) >= 4 for item in self.workQueue): reward += 15.0 if self.taskId == 4: reward += 20.0 if self.crisisResolved else -40.0 return self._clampReward(reward) def reset(self, task_id: int = 1, seed: int = 42) -> SupergamesObservation: if task_id not in TASKS: raise ValueError(f"Unknown task_id: {task_id}") self.stateData = State(episode_id=str(uuid4()), step_count=0) self.taskId = task_id games, workQueue, staffPool, totalSteps, goal = TASKS[task_id]["generate"](seed) self.games = copy.deepcopy(games) self.workQueue = copy.deepcopy(workQueue) self.staffPool = copy.deepcopy(staffPool) self.totalSteps = totalSteps self.goal = goal # For task 4, crisis work should only become available starting sprint 2. self.pendingCrisisItems = [] if self.taskId == 4: self.pendingCrisisItems = [item for item in self.workQueue if item.crisis] self.workQueue = [item for item in self.workQueue if not item.crisis] self.completedItems = [] self.cumulativeRevenue = 0.0 self.crisisResolved = False self.done = False self.initialWorkQueue = copy.deepcopy(self.workQueue) self.initialStaffPool = copy.deepcopy(self.staffPool) # Coarse upper bound for multi-sprint normalization. baseRevenue = sum(game.monthlyRevenue for game in self.games) * self.totalSteps impactBound = 0.0 for item in self.workQueue + self.pendingCrisisItems: activeSprints = max(0, self.totalSteps - item.impactDelay) impactBound += item.revenueImpact * (activeSprints / max(1, self.totalSteps)) self.estimatedOptimalRevenue = max(1.0, round(baseRevenue + impactBound, 2)) return self.buildObservation(reward=0.0) def step(self, action: SupergamesAction) -> SupergamesObservation: if self.done: return self.buildObservation(reward=0.0) self.stateData.step_count += 1 reasoning = action.reasoning.strip() if self.taskId == 4 and self.stateData.step_count >= 2 and self.pendingCrisisItems: self.workQueue.extend(self.pendingCrisisItems) self.pendingCrisisItems = [] ( self.games, self.workQueue, completedItems, sprintRevenue, message, ) = simulateSprint( self.games, self.workQueue, self.staffPool, action, self.stateData.step_count, ) self.cumulativeRevenue += sprintRevenue self.completedItems.extend(completedItems) sprintSummary = self._buildSprintSummary(sprintRevenue, completedItems) if self.taskId == 4 and any(item.crisis for item in completedItems): self.crisisResolved = True self.done = self.stateData.step_count >= self.totalSteps reward = self.computeReward(sprintRevenue, completedItems, message) obs = self.buildObservation(reward=reward) obs.metadata.update( { "stepRevenue": round(sprintRevenue, 2), "message": message, "sprint_summary": sprintSummary, } ) if reasoning: obs.metadata["agent_reasoning"] = reasoning return obs @property def state(self) -> State: return self.stateData