Skip to content

Commit

Permalink
implement in game events mechanism, some refactoring & update message…
Browse files Browse the repository at this point in the history
… colors
  • Loading branch information
linomp committed Jul 12, 2024
1 parent 85cfe75 commit 4a13d4e
Show file tree
Hide file tree
Showing 14 changed files with 175 additions and 144 deletions.
1 change: 1 addition & 0 deletions mvp/client/ui/src/api/generated/models/GameSessionDTO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ export type GameSessionDTO = {
game_over_reason?: (string | null);
final_score?: (number | null);
user_messages?: Record<string, UserMessage>;
cash_multiplier?: number;
};
9 changes: 7 additions & 2 deletions mvp/client/ui/src/components/MachineData.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,12 @@
}
.message-card.WARNING {
background-color: #ffffe0; /* Light yellow */
color: #000000; /* Black */
background-color: #ffffe0;
color: #000000;
}
.message-card.INFO {
background-color: rgba(129, 248, 94, 0.86);
color: #000000;
}
</style>
13 changes: 10 additions & 3 deletions mvp/client/ui/src/components/SessionData.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import {type GameSessionDTO, PlayerActionsService, SessionsService,} from "src/api/generated";
import {formatNumber, isNotUndefinedNorNull, isUndefinedOrNull,} from "src/shared/utils";
import {type GameSessionDTO, PlayerActionsService, SessionsService} from "src/api/generated";
import {formatNumber, isNotUndefinedNorNull, isUndefinedOrNull} from "src/shared/utils";
import {
dayInProgress,
gameOver,
Expand Down Expand Up @@ -67,7 +67,9 @@
{#if isNotUndefinedNorNull($gameSession) && !$gameOver}
<div class="session-data">
<p>Current Step: {$gameSession?.current_step}</p>
<p>Available Funds: {formatNumber($gameSession?.available_funds)}</p>
<p class:highlight-funds={($gameSession?.cash_multiplier??0) > 1}>
Available Funds: {formatNumber($gameSession?.available_funds)}
</p>
<div class={`session-controls ${$isOnNarrowScreen ? "flex-row" : "flex-col"}`}>
<button on:mousedown={advanceToNextDay} disabled={$dayInProgress}>
Advance to next day
Expand Down Expand Up @@ -96,4 +98,9 @@
.flex-col {
flex-direction: column;
}
.highlight-funds {
color: #00da86;
font-weight: bold;
}
</style>
2 changes: 1 addition & 1 deletion mvp/server/core/analysis/rul_prediction.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import numpy as np
import onnxruntime as rt

from mvp.server.core.machine.OperationalParameters import OperationalParameters
from mvp.server.core.machine.MachineState import OperationalParameters

# Load the SVR pipeline from ONNX file
onnx_path = "mvp/server/core/analysis/artifacts/svr_pipeline_23_06_24.onnx"
Expand Down
1 change: 1 addition & 0 deletions mvp/server/core/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@
MAINTENANCE_COST = 40
SENSOR_COST = 30
PREDICTION_MODEL_COST = 50
DEMAND_PEAK_EVENT_PROBABILITY = 0.2
48 changes: 34 additions & 14 deletions mvp/server/core/game/GameSession.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import os
import random
from datetime import datetime
from typing import Callable

Expand All @@ -9,8 +10,7 @@
from mvp.server.core.analysis.rul_prediction import default_rul_prediction_fn, svr_rul_prediction_fn
from mvp.server.core.constants import *
from mvp.server.core.game.UserMessage import UserMessage
from mvp.server.core.machine.MachineState import MachineState
from mvp.server.core.machine.OperationalParameters import OperationalParameters
from mvp.server.core.machine.MachineState import MachineState, OperationalParameters

load_dotenv()

Expand All @@ -30,6 +30,7 @@ class GameSession(BaseModel):
state_publish_function: Callable[["GameSession"], None]
rul_predictor: Callable[[int, OperationalParameters, list[str]], int | None] = default_rul_prediction_fn
user_messages: dict[str, UserMessage] = {}
cash_multiplier: int = 1

@staticmethod
def new_game_session(_id: str, _state_publish_function: Callable[["GameSession"], None]) -> "GameSession":
Expand Down Expand Up @@ -74,14 +75,21 @@ def update_game_over_flag(self) -> None:
f"{datetime.now()}: GameSession '{self.id}' - machine failed at step {self.current_step} - {self.machine_state}"
)

async def advance_one_turn(self) -> list[MachineState]:
async def advance_one_turn(self) -> list[MachineState] | None:
collected_machine_states_during_turn = []

self.last_updated = datetime.now()

# if there is a demand peak bonus up to this point, multiply the player cash for this turn!
if "demand_peak_bonus" in self.user_messages:
self.cash_multiplier = 2

self.user_messages.pop("demand_peak_bonus", None)

for _ in range(TIMESTEPS_PER_MOVE):
# collect stats
collected_machine_states_during_turn.append(self.machine_state)

if os.getenv("COLLECT_MACHINE_HISTORY", False):
collected_machine_states_during_turn.append(self.machine_state)

self.update_game_over_flag()
if self.is_game_over:
Expand All @@ -92,29 +100,41 @@ async def advance_one_turn(self) -> list[MachineState]:

# Player earns money for the production at every timestep,
# proportional to the health of the machine (bad health = less efficient production)
self.available_funds += (self.machine_state.health_percentage / 50) * REVENUE_PER_DAY / TIMESTEPS_PER_MOVE
self.available_funds += self.cash_multiplier * (
self.machine_state.health_percentage / 50) * REVENUE_PER_DAY / TIMESTEPS_PER_MOVE

# Publish state every 2 steps (to reduce the load on the MQTT broker)
if self.current_step % 2 == 0:
self.state_publish_function(self)

await asyncio.sleep(GAME_TICK_INTERVAL)

if (random.random() < DEMAND_PEAK_EVENT_PROBABILITY) or os.getenv("DEV_FORCE_DEMAND_PEAK_EVENT", False):
self.user_messages["demand_peak_bonus"] = UserMessage(
type="INFO",
content="Demand Peak! - Skip maintenance and earn 2x cash in the next turn!"
)

self.update_rul_prediction()
self.current_step += 1
self.cash_multiplier = 1

self.machine_state_history.extend(
zip(
range(self.current_step - TIMESTEPS_PER_MOVE, self.current_step),
collected_machine_states_during_turn
if os.getenv("COLLECT_MACHINE_HISTORY", False):
self.machine_state_history.extend(
zip(
range(self.current_step - TIMESTEPS_PER_MOVE, self.current_step),
collected_machine_states_during_turn
)
)
)

return collected_machine_states_during_turn
return collected_machine_states_during_turn

def do_maintenance(self) -> bool:
if self.available_funds < MAINTENANCE_COST:
return False

# if there was a demand peak bonus and player does maintenance, it gets cleared
self.user_messages.pop("demand_peak_bonus", None)

self.current_step += 1
self.available_funds -= MAINTENANCE_COST
self.machine_state.do_maintenance()
Expand Down Expand Up @@ -146,7 +166,7 @@ def purchase_prediction(self, prediction: str) -> bool:
return True

def update_rul_prediction(self) -> None:
self.user_messages.clear()
self.user_messages.pop("rul_accuracy_warning", None)

# TODO: clean up this stuff; it feels awkward having to iterate when there is only 1 type of prediction...
for prediction, purchased in self.available_predictions.items():
Expand Down
7 changes: 5 additions & 2 deletions mvp/server/core/game/GameSessionDTO.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class GameSessionDTO(BaseModel):
game_over_reason: str | None = None
final_score: float | None = None
user_messages: dict[str, UserMessage] = {}
cash_multiplier: int = 1

@staticmethod
def from_session(session: GameSession) -> "GameSessionDTO":
Expand All @@ -27,7 +28,8 @@ def from_session(session: GameSession) -> "GameSessionDTO":
is_game_over=session.is_game_over,
machine_state=MachineStateDTO.from_machine_state(session.machine_state),
final_score=None,
user_messages=session.user_messages
user_messages=session.user_messages,
cash_multiplier=session.cash_multiplier
)

if session.is_game_over:
Expand Down Expand Up @@ -55,5 +57,6 @@ def from_dict(json: dict[str, Any]) -> "GameSessionDTO":
machine_state=MachineStateDTO.from_dict(json.get("machine_state", {})),
available_funds=json.get("available_funds", 0.),
is_game_over=json.get("is_game_over", False),
user_messages=json.get("user_messages", {})
user_messages=json.get("user_messages", {}),
cash_multiplier=json.get("current_step", 1),
)
1 change: 0 additions & 1 deletion mvp/server/core/game/UserMessage.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,3 @@
class UserMessage(BaseModel):
type: Literal["WARNING", "INFO"]
content: str
seen: bool = False
114 changes: 110 additions & 4 deletions mvp/server/core/machine/MachineState.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,117 @@
import math
import random
from typing import Any, Callable

from pydantic import BaseModel

from mvp.server.core.constants import TEMPERATURE_STARTING_POINT, HEALTH_RECOVERY_FACTOR_ON_MAINTENANCE, \
MECHANICAL_WEAR_REDUCTION_FACTOR_ON_MAINTENANCE
from mvp.server.core.machine.OperationalParameters import OperationalParameters
from mvp.server.core.math_utils import constrain_from_0_to_100
from mvp.server.core.constants import *
from mvp.server.core.math_utils import linear_growth_with_reset, map_value, exponential_decay, constrain_from_0_to_100


class OperationalParameters(BaseModel):
temperature: float | None
oil_age: float | None
mechanical_wear: float | None

def get_purchasable_sensors(self) -> set[str]:
return self.model_fields_set

def update(self, current_timestep: int) -> None:
self.temperature = self.compute_machine_temperature(current_timestep)
self.oil_age = self.compute_oil_age(current_timestep)
self.mechanical_wear = self.compute_mechanical_wear(current_timestep)

def compute_health_percentage(self, current_timestep: int, current_health: float) -> float:
raw_value = round(
exponential_decay(
current_timestep,
initial_value=current_health,
decay_speed=self.compute_decay_speed()
)
)

raw_value -= random.random() * (0.005 * raw_value)

return constrain_from_0_to_100(raw_value)

def compute_decay_speed(self) -> float:
# TODO: calibrate these weights
temperature_weight = 0.01
oil_age_weight = 0.001
mechanical_wear_weight = 0.1

# Made-up calculation involving operational parameters: temperature, oil age, mechanical wear
computed = self.temperature * temperature_weight + \
self.oil_age * oil_age_weight + \
self.mechanical_wear * mechanical_wear_weight

mapping_max = TEMPERATURE_MAPPING_MAX * temperature_weight + \
OIL_AGE_MAPPING_MAX * oil_age_weight + \
MECHANICAL_WEAR_MAPPING_MAX * mechanical_wear_weight

computed = min(mapping_max, computed)

return map_value(computed, from_low=0, from_high=mapping_max, to_low=0, to_high=0.005)

def compute_machine_temperature(self, current_timestep: int) -> float:
# temperature grows linearly over the 8 hours of a shift (resets every 8 hours)
raw_value = self.mechanical_wear * linear_growth_with_reset(
initial_value=0,
period=TIMESTEPS_PER_MOVE,
current_timestep=current_timestep
)

raw_value -= random.random() * raw_value

return map_value(
raw_value,
from_low=0,
from_high=TIMESTEPS_PER_MOVE - 1,
to_low=TEMPERATURE_STARTING_POINT,
to_high=TEMPERATURE_MAPPING_MAX
)

def compute_oil_age(self, current_timestep: int) -> float:
# oil age grows monotonically and resets only after every maintenance routine
raw_value = min(1e6, self.oil_age + ((current_timestep / 1000) * (self.temperature ** 2)))
raw_value += random.random() * raw_value

return map_value(
raw_value,
from_low=self.oil_age,
from_high=1e6,
to_low=self.oil_age,
to_high=OIL_AGE_MAPPING_MAX
)

def compute_mechanical_wear(self, current_timestep: int) -> float:
# mechanical wear grows monotonically, directly proportional to oil ag.
# for now it never resets (such that at some point, the machine will definitely break and game over)
raw_value = min(1e6, math.exp(current_timestep / 200) * self.oil_age)
raw_value += random.random() * raw_value

return map_value(
raw_value,
from_low=0,
from_high=1e6,
to_low=self.mechanical_wear,
to_high=MECHANICAL_WEAR_MAPPING_MAX
)

def to_dict(self) -> dict[str, float]:
return {
"temperature": self.temperature,
"oil_age": self.oil_age,
"mechanical_wear": self.mechanical_wear
}

@staticmethod
def from_dict(json: dict[str, float]) -> 'OperationalParameters':
return OperationalParameters(
temperature=json.get("temperature", 0),
oil_age=json.get("oil_age", 0),
mechanical_wear=json.get("mechanical_wear", 0)
)


class MachineState(BaseModel):
Expand Down
3 changes: 1 addition & 2 deletions mvp/server/core/machine/MachineStateDTO.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

from pydantic import BaseModel

from mvp.server.core.machine import MachineState
from mvp.server.core.machine.OperationalParameters import OperationalParameters
from mvp.server.core.machine.MachineState import MachineState, OperationalParameters


class MachineStateDTO(BaseModel):
Expand Down
Loading

0 comments on commit 4a13d4e

Please sign in to comment.