diff --git a/config.template b/config.template index 44bcb85..15368dc 100644 --- a/config.template +++ b/config.template @@ -52,6 +52,9 @@ whoami = True DadJokes = True DadJokesEmoji = False +# weekday command shows day of week, day of year, days left in year, week number, days till Christmas, and past date info +weekday = True + # enable or disable the Solar module spaceWeather = True @@ -460,8 +463,10 @@ golfsim = True hangman = True hamtest = True tictactoe = True +lunarlander = True wordOfTheDay = True battleShip = True +potatogunner = True # enable or disable the quiz game module questions are in data/quiz.json quiz = False diff --git a/mesh_bot.py b/mesh_bot.py index 16568bc..fff9986 100755 --- a/mesh_bot.py +++ b/mesh_bot.py @@ -16,7 +16,8 @@ import modules.settings as my_settings from modules.system import * # list of commands to remove from the default list for DM only -restrictedCommands = ["blackjack", "videopoker", "dopewars", "lemonstand", "golfsim", "mastermind", "hangman", "hamtest", "tictactoe", "tic-tac-toe", "quiz", "q:", "survey", "s:", "battleship", "football"] +restrictedCommands = ["blackjack", "videopoker", "dopewars", "lemonstand", "golfsim", "mastermind", "hangman", "hamtest", "tictactoe", "tic-tac-toe", "quiz", "q:", "survey", "s:", "battleship", "spudgun", "spudgunner", "lunarlander", "football"] + restrictedResponse = "๐Ÿค–only available in a Direct Message๐Ÿ“ต" # "" for none blackhole_mode = False @@ -91,6 +92,7 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n "leaderboard": lambda: get_mesh_leaderboard(message, message_from_id, deviceID), "lemonstand": lambda: handleLemonade(message, message_from_id, deviceID), "lheard": lambda: handle_lheard(message, message_from_id, deviceID, isDM), + "lunarlander": lambda: handleLunarLander(message, message_from_id, deviceID), "map": lambda: mapHandler(message_from_id, deviceID, channel_number, message, snr, rssi, hop), "mastermind": lambda: handleMmind(message, message_from_id, deviceID), "messages": lambda: handle_messages(message, deviceID, channel_number, msg_history, publicChannel, isDM), @@ -112,6 +114,8 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n "sitrep": lambda: handle_lheard(message, message_from_id, deviceID, isDM), "sms:": lambda: handle_sms(message_from_id, message), "solar": lambda: drap_xray_conditions() + "\n" + solar_conditions() + "\n" + get_noaa_scales_summary(), + "spudgun": lambda: handlePotatoGunner(message, message_from_id, deviceID), + "spudgunner": lambda: handlePotatoGunner(message, message_from_id, deviceID), "sun": lambda: handle_sun(message_from_id, deviceID, channel_number), "survey": lambda: surveyHandler(message, message_from_id, deviceID), "s:": lambda: surveyHandler(message, message_from_id, deviceID), @@ -124,6 +128,7 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n "valert": lambda: get_volcano_usgs(), "verse": lambda: read_verse(), "videopoker": lambda: handleVideoPoker(message, message_from_id, deviceID), + "weekday": lambda: weekdayHandler(message, message_from_id, deviceID), "whereami": lambda: handle_whereami(message_from_id, deviceID, channel_number), "whoami": lambda: handle_whoami(message_from_id, deviceID, hop, snr, rssi, pkiStatus), "whois": lambda: handle_whois(message, deviceID, channel_number, message_from_id), @@ -1167,6 +1172,79 @@ def handleFootball(message, nodeID, deviceID): # Play command msg = football.play(nodeID, message) +def handleLunarLander(message, nodeID, deviceID): + global lunarlanderTracker + from modules.settings import use_metric + + # Strip command prefix from message + user_input = message.replace("lunarlander", "").strip() + + tracker_entry = next((entry for entry in lunarlanderTracker if entry['nodeID'] == nodeID), None) + + # Create new game if not found + if not tracker_entry: + game_state, welcome_msg = lunarlander.new_game() + lunarlanderTracker.append({ + "nodeID": nodeID, + "last_played": time.time(), + "game_state": game_state, + "result": None + }) + # If user sent something besides just the command, process it as first burn rate + if user_input: + updated_state, response, game_ended = lunarlander.play(game_state, user_input, use_metric) + lunarlanderTracker[-1]["game_state"] = updated_state + if game_ended: + lunarlanderTracker.pop() + return response + return welcome_msg + + # Update last played + tracker_entry["last_played"] = time.time() + + # Handle end/exit command for active game + if user_input.lower() in ('e', 'end', 'exit', 'quit'): + lunarlanderTracker.remove(tracker_entry) + return "๐Ÿš€ Thanks for landing! Type 'lunarlander' to play again." + + # Process player input + game_state = tracker_entry.get("game_state", {}) + updated_state, response, game_ended = lunarlander.play(game_state, user_input, use_metric) + + # Save updated state + tracker_entry["game_state"] = updated_state + tracker_entry["result"] = response if game_ended else None + + # Remove from tracker if game ended + if game_ended: + lunarlanderTracker.remove(tracker_entry) + + return response +def handlePotatoGunner(message, nodeID, deviceID): + global potatogunnerTracker + + tracker_entry = next((entry for entry in potatogunnerTracker if entry['nodeID'] == nodeID), None) + + # Handle end/exit command + if message.lower().startswith('e'): + if tracker_entry: + potatogunner.end(nodeID) + potatogunnerTracker.remove(tracker_entry) + return "๐Ÿฅ” Thanks for playing! ๐Ÿฅ”" + + # If not found, create new tracker entry + if not tracker_entry: + potatogunnerTracker.append({ + "nodeID": nodeID, + "last_played": time.time() + }) + msg = "๐Ÿฅ” POTATO GUNNER ๐Ÿฅ” Let's plant some spuds!\n" + msg += potatogunner.new_game(nodeID) + return msg + else: + tracker_entry["last_played"] = time.time() + + msg = potatogunner.play(nodeID, message) return msg @@ -2360,9 +2438,11 @@ gameTrackers = [ (hangmanTracker, "Hangman", handleHangman), (hamtestTracker, "HamTest", handleHamtest), (tictactoeTracker, "TicTacToe", handleTicTacToe), + (lunarlanderTracker, "LunarLander", handleLunarLander), (surveyTracker, "Survey", surveyHandler), (battleshipTracker, "Battleship", handleBattleship), (footballTracker, "Football", handleFootball), + (potatogunnerTracker, "PotatoGunner", handlePotatoGunner), # quiz does not use a tracker (quizGamePlayer) always active ] diff --git a/modules/games/README.md b/modules/games/README.md index b6d90d7..facd82d 100644 --- a/modules/games/README.md +++ b/modules/games/README.md @@ -2,6 +2,7 @@ ## Game Index +- [Lunar Lander](#lunar-lander-game-module) - [Blackjack](#blackjack-game-module) - [DopeWars](#dopewars-game-module) - [GolfSim](#golfsim-game-module) @@ -10,6 +11,7 @@ - [MasterMind](#mastermind-game-module) - [Battleship](#battleship-game-module) - [Football](#football-game-module) +- [Potato Gunner](#potato-gunner-game-module) - [Video Poker](#video-poker-game-module) - [Hangman](#hangman-game-module) - [Quiz](#quiz-game-module) @@ -20,6 +22,119 @@ --- +# Lunar Lander Game Module + +A classic Apollo-style lunar landing simulation adapted for the Meshtastic mesh-bot. Land your capsule safely on the moon by managing fuel and engine temperature! + +## How to Play + +- **Start the Game:** + Send the command `lunarlander` via DM to the bot to start a new mission. + +- **Landing Strategy:** + You must carefully control your descent rate and manage fuel to achieve a safe landing. + - **Target landing velocity:** Less than 0.5 mph for a perfect landing + - **Safe landing:** Below 2 mph + - **Rough landing:** Below 30 mph (crew survives) + - **Crash:** 30-100 mph (crew likely injured) + - **Catastrophic failure:** 100+ mph (new crater! ๐Ÿ’€) + +- **Controls:** + Enter burn rate and optional duration on each turn. + - Format: `burn_rate [duration]` + - Burn rate: 0-200 lbs/sec (safe), higher = risky + - Duration: 1-30 seconds (default 10) + - Examples: `100`, `150 5`, `0` + +- **Fuel Management:** + - Starting fuel varies (keeps games fresh!) + - Watch your remaining fuel time + - Free fall (0 lbs/sec burn) conserves fuel but increases impact risk + - High burn rate uses fuel fast but gives descent control + + +## Commands + +| Command | Effect | +|---------|--------| +| `lunarlander` | Start a new game | +| `help` or `?` or `h` | Show controls | +| `quit` or `exit` or `end` | Abort mission | +| `y` | Confirm risky high burn (>200 lbs/sec) | +| `n` | Cancel risky burn | + +## Status Display + +During each turn you'll see: +``` +โฑ๏ธ T+ 120s | ๐ŸŒ 45mi 2640ft | ๐Ÿ“‰ 1200mph +โ›ฝ Fuel: 8500lbs | ๐Ÿ”ฅ 120 lbs/s (70s left) | ๐ŸŒก๏ธ 65% +๐ŸŸ  WARNING: Engine temp rising - watch it! +``` + +## Tips for Winning + +1. **Early descents:** High initial velocity = more burn needed early +2. **Fuel efficiency:** Lower burn rates = longer mission, but slow descent control +3. **Engine management:** Stay below 70% temp; if warned, reduce burn rate next turn +4. **Final approach:** Use a gentle burn (10-30 lbs/sec) for precision landing +5. **Overheat risk:** Burning 200+ lbs/sec heats engine fast; only use when desperate! +6. **Free fall:** Use 0 lbs/sec strategically to drop quickly then burn hard for landing + +## Game State + +- Game state persists per player (tracked by node ID) +- Game automatically expires after 8 hours of inactivity +- Only one active mission per player +- For best results, play via DM to avoid channel spam + +## Example Session + +``` +๐Ÿš€ LUNAR LANDER ๐Ÿš€ +Your onboard computer crashed (Boeing made it ๐Ÿ˜ฌ) +YOU must land this capsule manually! + +๐Ÿ’ก Enter: burn_rate [duration_sec] +๐Ÿ“Š Examples: '100' (10s default) or '150 5' +โฑ๏ธ Watch descent & fuel! Type 'help' for more + +โฑ๏ธ T+ 10s | ๐ŸŒ 125mi 4800ft | ๐Ÿ“‰ 450mph +โ›ฝ Fuel: 16000lbs | ๐ŸŽฏ Descent rate: excellent + +โ†’ Enter burn rate: +``` +``` +100 + +โฑ๏ธ T+ 20s | ๐ŸŒ 110mi 1200ft | ๐Ÿ“‰ 850mph +โ›ฝ Fuel: 14000lbs | ๐Ÿ”ฅ 100 lbs/s (140s left) | ๐ŸŒก๏ธ 25% +โœ… Descent rate: controlled + +โ†’ Next burn rate: +``` +``` +150 5 + +๐Ÿ‘ฝ ALIENS! They gave us 800 lbs fuel! ๐Ÿ’š + +โฑ๏ธ T+ 25s | ๐ŸŒ 85mi 3400ft | ๐Ÿ“‰ 1200mph +โ›ฝ Fuel: 14800lbs | ๐Ÿ”ฅ 150 lbs/s (98s left) | ๐ŸŒก๏ธ 47% +โšก Descent rate: steady, watch it + +โ†’ Next burn rate: +``` + +## Credits + +- Classic Lunar Lander by Dave LeCompte (1979) +- Ported to Meshtastic mesh-bot by K7MHI Kelly Keeton 2026 +- Physics engine: Euler method numerical integration +- Original inspiration: NASA Apollo Lunar Module simulations + +--- + + # Blackjack Game Module This module implements a classic game of Blackjack (Casino 21) for the Meshtastic mesh-bot. @@ -445,6 +560,121 @@ Would you like to play again? (N)ormal, (H)ard, or e(X)pert? - Ported from [pwdkramer/pythonMastermind](https://github.com/pwdkramer/pythonMastermind) - Adapted for Meshtastic mesh-bot by K7MHI Kelly Keeton 2024 +# Potato Gunner Game Module + +A silly, potato-themed artillery game for the Meshtastic mesh-bot. Fire spuds at moving targets, navigate wind and chaos events, and earn powerups to become the ultimate potato sharpshooter! + +## How to Play + +- **Start the Game:** + Send the command `spudgun`, or `spudgunner` via DM to the bot to start a new tournament. + +- **Gameplay:** + Each round, you'll see the target distance and wind conditions. Enter an elevation angle (0-90ยฐ) AND hairspray PSI pressure (0-100) to control your shot's velocity. + - **Input format:** `angle,power` (e.g., `45,60` for 45ยฐ at 60 PSI) + - **Quick hints:** `low,50` | `mid,50` | `high,50` (uses default 50 PSI) + - **Backward compatible:** Just enter angle (e.g., `45`) for default 50 PSI + - **Power mechanics:** + - 10 PSI: Short range, easy to control + - 50 PSI: Medium range, balanced (default) + - 100 PSI: Maximum range, harder to control + - **Accuracy radius:** Shots within the accuracy range hit the target (base 150 yards) + - **Wind effects:** Wind can increase or decrease your effective distance + - **Chaos events:** Random silly events like earthquakes, meteor showers, alien invasions, and more make aiming tricky! + +- **Commands:** + - `,` โ€” Fire spud at that angle with that PSI power (e.g., `45,60`) + - `` โ€” Fire at default 50 PSI (backward compatible, e.g., `45`) + - `low`, `mid`, `high` โ€” Use quick elevation hints at 50 PSI + - `stats` โ€” Display session statistics + - `new` โ€” Advance to next round + - `e` โ€” End tournament + +- **Scoring:** + - Base score: 100 points per hit + - Accuracy bonus: Up to +100 points (closer hits = more points) + - Streak bonus: +15 points for each consecutive hit + - Chaos survival bonus: +60 points for hitting during chaos events + - Round multiplier: Score multiplies by round number + +- **Powerups (Earned on Good Shots):** + - ๐ŸŽฏ **Accuracy โ‰ฅ75%**: Homing Spud (auto-corrects aim by 15%) + - โšก **Accuracy โ‰ฅ50%**: Super Spud (+60 yard accuracy radius) + - ๐Ÿ”ฅ **3+ hit streak**: Potato Shield (free miss, one-time use) + - ๐Ÿ€ **Chaos survival**: Lucky Potato (auto-negates next chaos event) + +- **Difficulty Levels:** + - Target distance expands each round + - Wind intensity increases progressively + - Chaos events become more frequent and intense + - Round multiplier increases with score + +- **High Scores:** + The module tracks your best tournament score. Beat it to earn a "NEW HIGH SCORE!" message and lock in your achievement! + +- **Example Game Flow:** + +``` +๐Ÿฅ” **Welcome to POTATO GUNNER!** ๐Ÿฅ” +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” +๐Ÿ† **High Score: 1250 pts** ๐Ÿ† + +๐Ÿฅ” **ROUND 1 - POTATO ARTILLERY RANGE** ๐Ÿฅ” +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” +๐ŸŽฏ **Target Distance:** ~28425yd +๐Ÿ’จ **Wind:** +3yd (slight breeze) +๐ŸŒช๏ธ **Difficulty:** GENTLE + +> 45 + +๐ŸŽฏ **Target Distance:** ~28425yd +๐Ÿ’จ **Wind:** +3yd (slight breeze) +๐ŸŒช๏ธ **Difficulty:** GENTLE + +> 45,60 + +๐Ÿ’ฃ SPUD FIRED AT 45.0ยฐ with 60 PSI +Velocity: 120m/s | Wind effect: +2yd +**Actual distance: 28510yd** | Target: 28425yd +Spread: 85yd (hit zone: ยฑ150yd) + +โœ… **HIT!** ๐ŸŽฏ +Excellent accuracy! +165 pts earned! + +๐Ÿ“Š Session: 165 pts | Accuracy: 1/1 (100%) +Type 'new' for next round or 'e' to end: +``` + +## Notes + +- Single session per player (new game cancels previous session) +- Game sessions can span multiple rounds (10+ recommended for full experience) +- Each round becomes progressively harder with increased distance and wind +- Power affects velocity: higher PSI = longer range, more damage potential +- Chaos events add fun disruption - sometimes hitting during chaos is worth more! +- Play via DM for best experience and to avoid interference with other players +- High scores are stored persistently in `data/potatogunner_hs.pkl` + +## Tips for Success + +- Start with medium power (50 PSI) for balanced range, adjust based on target distance +- Use low power (10-30 PSI) for close targets or high precision needs +- Use high power (70-100 PSI) for distant targets or when you need maximum range +- Watch the wind direction and adjust your aim accordingly +- Land consecutive hits to build streaks and earn powerups +- Use Lucky Potato to negate difficult chaos events +- Push for high accuracy (75%+) to earn the "SHARPSHOOTER" rating +- Experiment with power/angle combinations to find what works best! + +## Chaos Events (12 types) + +Wind Gust, Earthquake, Rain Storm, Meteor Shower, Alien Invasion, Mischievous Squirrel, Bird Swarm, Angry Farmer, Gravity Flip, Inverse Wind, Ricochet Zone, Slow Motion โ€” each with escalating intensity as you progress! + +## Credits + +- Based on the classic [Gunner Artillery Game](https://github.com/coding-horror/basic-computer-games/blob/main/42_Gunner/python/gunner.py) +- Adapted and enhanced with chaos system, powerups, and potato theming for Meshtastic mesh-bot by K7MHI Kelly Keeton 2025 + # Video Poker Game Module A text-based Video Poker game for the Meshtastic mesh-bot. Play classic five-card draw poker, place your bets, and try to build your bankroll! diff --git a/modules/games/lunarlander.py b/modules/games/lunarlander.py new file mode 100644 index 0000000..c3e8373 --- /dev/null +++ b/modules/games/lunarlander.py @@ -0,0 +1,813 @@ +""" +Lunar landing simulation +https://github.com/coding-horror/basic-computer-games/blob/main/59_Lunar_LEM_Rocket/python/lunar.py +Refactored for Meshtastic Mesh Bot 2026 K7MHI +""" + +import math +import random +from dataclasses import dataclass, asdict +from typing import Any, NamedTuple, Tuple, Optional, Dict + +PAGE_WIDTH = 64 + +# Unit conversion helpers +def get_distance_display(altitude_miles: float, use_metric: bool = False) -> Tuple[float, str]: + """ + Convert altitude to display units. + Shows miles (or km) until under 1 mile, then switches to feet (or meters). + + Args: + altitude_miles: Altitude in miles + use_metric: If True, convert to km/m; if False, use miles/feet + + Returns: + Tuple[float, str]: (value, unit_label) + """ + if use_metric: + # Convert miles to kilometers + altitude_km = altitude_miles * 1.60934 + if altitude_km >= 1: + return int(altitude_km), "km" + else: + m = int(altitude_km * 1000) + return m, "m" + else: + # Show miles until under 1 mile, then switch to feet + if altitude_miles >= 1: + return int(altitude_miles), "mi" + else: + feet = int(altitude_miles * 5280) + return feet, "ft" + +def get_speed_display(velocity_mph: int, use_metric: bool = False) -> Tuple[int, str]: + """ + Convert velocity to display units. + + Args: + velocity_mph: Velocity in mph + use_metric: If True, convert to m/s; if False, keep as mph + + Returns: + Tuple[int, str]: (speed_value, unit_label) + """ + if use_metric: + # Convert mph to m/s (1 mph = 0.44704 m/s) + velocity_ms = int(velocity_mph * 0.44704) + return velocity_ms, "m/s" + else: + return velocity_mph, "mph" + +COLUMN_WIDTH = 2 +SECONDS_WIDTH = 4 +MPH_WIDTH = 6 +ALT_MI_WIDTH = 6 +ALT_FT_WIDTH = 4 +MPH_WIDTH = 6 +FUEL_WIDTH = 8 +BURN_WIDTH = 10 + +SECONDS_LEFT = 0 +SECONDS_RIGHT = SECONDS_LEFT + SECONDS_WIDTH +ALT_LEFT = SECONDS_RIGHT + COLUMN_WIDTH +ALT_MI_RIGHT = ALT_LEFT + ALT_MI_WIDTH +ALT_FT_RIGHT = ALT_MI_RIGHT + COLUMN_WIDTH + ALT_FT_WIDTH +MPH_LEFT = ALT_FT_RIGHT + COLUMN_WIDTH +MPH_RIGHT = MPH_LEFT + MPH_WIDTH +FUEL_LEFT = MPH_RIGHT + COLUMN_WIDTH +FUEL_RIGHT = FUEL_LEFT + FUEL_WIDTH +BURN_LEFT = FUEL_RIGHT + COLUMN_WIDTH +BURN_RIGHT = BURN_LEFT + BURN_WIDTH + + +class PhysicalState(NamedTuple): + velocity: float + altitude: float + + +def add_rjust(line: str, s: Any, pos: int) -> str: + """Add a new field to a line right justified to end at pos""" + s_str = str(s) + slen = len(s_str) + if len(line) + slen > pos: + new_len = pos - slen + line = line[:new_len] + if len(line) + slen < pos: + spaces = " " * (pos - slen - len(line)) + line = line + spaces + return line + s_str + + +def add_ljust(line: str, s: str, pos: int) -> str: + """Add a new field to a line left justified starting at pos""" + s = s + if len(line) > pos: + line = line[:pos] + if len(line) < pos: + spaces = " " * (pos - len(line)) + line = line + spaces + return line + s + + +def format_line_for_report( + t: Any, + miles: Any, + feet: Any, + velocity: Any, + fuel: Any, + burn_rate: str, + is_header: bool, +) -> str: + line = add_rjust("", t, SECONDS_RIGHT) + line = add_rjust(line, miles, ALT_MI_RIGHT) + line = add_rjust(line, feet, ALT_FT_RIGHT) + line = add_rjust(line, velocity, MPH_RIGHT) + line = add_rjust(line, fuel, FUEL_RIGHT) + if is_header: + line = add_rjust(line, burn_rate, BURN_RIGHT) + else: + line = add_ljust(line, burn_rate, BURN_LEFT) + return line + + +class SimulationClock: + def __init__(self, elapsed_time: float, time_until_next_prompt: float) -> None: + self.elapsed_time = elapsed_time + self.time_until_next_prompt = time_until_next_prompt + + def time_for_prompt(self) -> bool: + return self.time_until_next_prompt < 1e-3 + + def advance(self, delta_t: float) -> None: + self.elapsed_time += delta_t + self.time_until_next_prompt -= delta_t + + def to_dict(self) -> Dict[str, float]: + """Serialize clock state for storage""" + return { + "elapsed_time": self.elapsed_time, + "time_until_next_prompt": self.time_until_next_prompt, + } + + @classmethod + def from_dict(cls, data: Dict[str, float]) -> "SimulationClock": + """Deserialize clock state from storage""" + return cls(data["elapsed_time"], data["time_until_next_prompt"]) + + +@dataclass +class Capsule: + altitude: float = 120 # in miles above the surface + velocity: float = 1 # downward + m: float = 33000 # mass_with_fuel + n: float = 16500 # mass_without_fuel + g: float = 1e-3 + z: float = 1.8 + fuel_per_second: float = 0 + engine_temp: float = 0 # 0-100% (0=cold, 100=failure) + + def remaining_fuel(self) -> float: + return self.m - self.n + + def is_out_of_fuel(self) -> bool: + return self.remaining_fuel() < 0.1 + + def update_state( + self, sim_clock: SimulationClock, delta_t: float, new_state: PhysicalState + ) -> None: + sim_clock.advance(delta_t) + self.m = self.m - delta_t * self.fuel_per_second + # Clamp fuel to not go negative + if self.m < self.n: + self.m = self.n + # Fuel is now empty, stop burning + self.fuel_per_second = 0 + self.altitude = new_state.altitude + self.velocity = new_state.velocity + + def fuel_time_remaining(self) -> float: + # extrapolates out how many seconds we have at the current fuel burn rate + assert self.fuel_per_second > 0 + return self.remaining_fuel() / self.fuel_per_second + + def predict_motion(self, delta_t: float) -> PhysicalState: + # Perform an Euler's Method numerical integration of the equations of motion. + + q = delta_t * self.fuel_per_second / self.m + + # new velocity + new_velocity = ( + self.velocity + + self.g * delta_t + + self.z * (-q - q**2 / 2 - q**3 / 3 - q**4 / 4 - q**5 / 5) + ) + + # new altitude + new_altitude = ( + self.altitude + - self.g * delta_t**2 / 2 + - self.velocity * delta_t + + self.z + * delta_t + * (q / 2 + q**2 / 6 + q**3 / 12 + q**4 / 20 + q**5 / 30) + ) + + return PhysicalState(altitude=new_altitude, velocity=new_velocity) + + def make_state_display_string(self, sim_clock: SimulationClock, use_metric: bool = False) -> str: + seconds = sim_clock.elapsed_time + velocity = int(3600 * self.velocity) # mph (always calculated) + fuel = int(self.remaining_fuel()) + + # Get distance and speed in proper units + alt_value, alt_label = get_distance_display(self.altitude, use_metric) + speed, speed_label = get_speed_display(velocity, use_metric) + + # Build engaging status display + alt_str = f"{alt_value} {alt_label}" + msg = f"โฑ๏ธ T+{seconds:>6.0f}s | ๐ŸŒ {alt_str:>10} | ๐Ÿ“‰ {speed:>5}{speed_label}\n" + msg += f"โ›ฝ Fuel: {fuel:>6}lbs " + + if self.fuel_per_second > 0: + fuel_seconds = self.fuel_time_remaining() + msg += f"| ๐Ÿ”ฅ {self.fuel_per_second:>5.0f}lbs/s ({fuel_seconds:>4.0f}s left)" + + # Add engine temp indicator if burning + if self.engine_temp > 0: + msg += f" | ๐ŸŒก๏ธ {self.engine_temp:.0f}%" + + # Add status indicator + msg += "\n" + + # Engine temp warnings take priority + if self.engine_temp > 90: + msg += "๐Ÿ”ด CRITICAL: ENGINE OVERHEAT! Reduce burn NOW!" + elif self.engine_temp > 70: + msg += "๐ŸŸ  WARNING: Engine temp rising - watch it!" + elif speed > (3000 if not use_metric else int(3000 * 0.44704)): + msg += "๐Ÿšจ CRITICAL: Falling fast! Burn harder NOW!" + elif speed > (1500 if not use_metric else int(1500 * 0.44704)): + msg += "โš ๏ธ WARNING: High descent rate - increase burn" + elif speed > (500 if not use_metric else int(500 * 0.44704)): + msg += "โšก Descent rate: steady, watch it" + elif speed > (100 if not use_metric else int(100 * 0.44704)): + msg += "โœ… Descent rate: controlled" + else: + msg += "๐ŸŽฏ Descent rate: excellent" + + return msg + + def to_dict(self) -> Dict[str, float]: + """Serialize capsule state for storage""" + return { + "altitude": self.altitude, + "velocity": self.velocity, + "m": self.m, + "n": self.n, + "g": self.g, + "z": self.z, + "fuel_per_second": self.fuel_per_second, + "engine_temp": self.engine_temp, + } + + @classmethod + def from_dict(cls, data: Dict[str, float]) -> "Capsule": + """Deserialize capsule state from storage""" + return cls( + altitude=data["altitude"], + velocity=data["velocity"], + m=data["m"], + n=data["n"], + g=data["g"], + z=data["z"], + fuel_per_second=data["fuel_per_second"], + engine_temp=data.get("engine_temp", 0), + ) + + + + +def format_engine_failure(sim_clock: SimulationClock, capsule: Capsule) -> str: + """Format engine failure outcome""" + mission_time = sim_clock.elapsed_time + altitude_at_failure = capsule.altitude + velocity_at_failure = int(3600 * capsule.velocity) + + result = f"\n๐Ÿ’ฅ ENGINE FAILURE AT T+{mission_time:.1f}s\n" + result += f"๐Ÿ“ Altitude: {int(altitude_at_failure)}mi\n" + result += f"๐Ÿ“‰ Velocity: {velocity_at_failure} mph (uncontrolled descent!)\n\n" + result += "๐Ÿ”ฅ Thrusters shut down! Overheating caused catastrophic failure!\n" + result += "โ˜ ๏ธ MISSION FAILED - Uncontrolled impact imminent! ๐Ÿ’€\n" + + return result + +def format_landing_result(sim_clock: SimulationClock, capsule: Capsule) -> str: + """Format landing outcome as a string with fun descriptions""" + w = 3600 * capsule.velocity # impact velocity in mph + mission_time = sim_clock.elapsed_time + fuel_used = 16500 - capsule.remaining_fuel() + + result = f"\n๐ŸŒ™ LUNAR TOUCHDOWN AT T+{mission_time:.1f}s\n" + result += f"๐Ÿ’ฅ Impact Velocity: {w:.1f} MPH\n" + result += f"โ›ฝ Fuel Used: {fuel_used:.0f} lbs\n\n" + + if w < 0.5: + result += "๐ŸŽฏ PERFECT! Textbook landing! NASA wants to hire you! ๐Ÿ†" + elif w < 2: + result += "โœจ FLAWLESS DESCENT! Gentle as a feather on the lunar surface! ๐ŸŒŸ" + elif w < 10: + result += "โœ… NICE LANDING! Crew is happy, minimal scratches on hull." + elif w < 30: + result += "โš ๏ธ ROUGH LANDING! Crew is shaken but safe. Some hull damage reported." + elif w < 60: + result += "๐Ÿ’” CRASH LANDING! Cabin pressure dropping... crew wounded but alive! ๐Ÿฅ" + elif w < 100: + result += "โ˜ ๏ธ KABOOM! You hit harder than a meteor! New crater created! ๐Ÿ’€" + else: + crater_size = w * 0.227 + result += f"โ˜ ๏ธ CATASTROPHIC FAILURE! You blasted a {crater_size:.0f} FOOT CRATER!\nMission: FAILED ๐Ÿ’€" + + return result + + +def show_landing(sim_clock: SimulationClock, capsule: Capsule) -> str: + """Return landing result and mark game as ended""" + return format_landing_result(sim_clock, capsule) + + +def show_out_of_fuel(sim_clock: SimulationClock, capsule: Capsule) -> str: + """Return out of fuel message and calculate final landing""" + msg = f"FUEL OUT AT {int(sim_clock.elapsed_time)} SECONDS\n" + delta_t = ( + -capsule.velocity + + math.sqrt(capsule.velocity**2 + 2 * capsule.altitude * capsule.g) + ) / capsule.g + capsule.velocity += capsule.g * delta_t + sim_clock.advance(delta_t) + msg += show_landing(sim_clock, capsule) + return msg + + +def process_final_tick( + delta_t: float, sim_clock: SimulationClock, capsule: Capsule +) -> str: + """Process final tick when landing and return result""" + # When we extrapolated our position based on our velocity + # and delta_t, we overshot the surface. For better + # accuracy, we will back up and do shorter time advances. + + while True: + if delta_t < 5e-3: + return show_landing(sim_clock, capsule) + # line 35 + average_vel = ( + capsule.velocity + + math.sqrt( + capsule.velocity**2 + + 2 + * capsule.altitude + * (capsule.g - capsule.z * capsule.fuel_per_second / capsule.m) + ) + ) / 2 + delta_t = capsule.altitude / average_vel + new_state = capsule.predict_motion(delta_t) + capsule.update_state(sim_clock, delta_t, new_state) + + +def handle_flyaway(sim_clock: SimulationClock, capsule: Capsule) -> bool: + """ + The user has started flying away from the moon. Since this is a + lunar LANDING simulation, we wait until the capsule's velocity is + positive (downward) before prompting for more input. + + Returns True if landed, False if simulation should continue. + """ + + while True: + w = (1 - capsule.m * capsule.g / (capsule.z * capsule.fuel_per_second)) / 2 + delta_t = ( + capsule.m + * capsule.velocity + / ( + capsule.z + * capsule.fuel_per_second + * math.sqrt(w**2 + capsule.velocity / capsule.z) + ) + ) + 0.05 + + new_state = capsule.predict_motion(delta_t) + + if new_state.altitude <= 0: + # have landed + return True + + capsule.update_state(sim_clock, delta_t, new_state) + + if (new_state.velocity > 0) or (capsule.velocity <= 0): + # return to normal sim + return False + + + +def check_engine_failure(burn_rate: float, engine_temp: float, burn_duration: float) -> Tuple[bool, float]: + """ + Check if engine fails during burn. + + Args: + burn_rate: Current burn rate (lbs/sec) + engine_temp: Current engine temperature (0-100%) + burn_duration: How long to burn (seconds) + + Returns: + Tuple[bool, float]: (engine_failed, new_temp) + - engine_failed: True if engine failure occurs + - new_temp: Updated engine temperature + """ + + # If not burning, apply cooling from previous burn + if burn_rate == 0: + # Cooling rates based on last burn intensity + # We estimate from current temp: if hot, we burned hard recently + if engine_temp > 50: + cooling = 15 # Was burning hard, cool slowly + elif engine_temp > 20: + cooling = 30 # Was burning moderately, cool faster + else: + cooling = 50 # Was barely burning or not, cool rapidly + + return False, max(0, engine_temp - cooling) + + # Calculate temp increase based on burn rate (heating while burning) + # 0-300 = safe, 300-450 = risky, >450 = very risky + # At 300 lbs/sec: minimal heating (0.5% per second max) + if burn_rate <= 100: + temp_increase = (burn_rate / 100) * 0.2 # 0-0.2% per second + elif burn_rate <= 300: + temp_increase = 0.2 + ((burn_rate - 100) / 200) * 0.3 # 0.2-0.5% per second + elif burn_rate <= 450: + temp_increase = 0.5 + ((burn_rate - 300) / 150) * 4.5 # 0.5-5% per second + else: + temp_increase = 5 + ((burn_rate - 450) / 100) * 25 # 5%+ per second + + # Calculate total temp increase over burn duration + new_temp = engine_temp + (temp_increase * burn_duration) + + # Random failure chance if overheating during the burn + if new_temp > 100: + # Guaranteed failure above 100% + return True, 100.0 + elif new_temp > 80: + # 50% failure chance above 80% + if random.random() < 0.5: + return True, new_temp + + # Return heated temp (cooling happens on next free-fall turn) + return False, new_temp + +def trigger_random_event(capsule: Capsule, sim_clock: SimulationClock) -> Tuple[str, float, float]: + """ + Randomly trigger space hazards or helpful aliens. + + Returns: + Tuple[str, float, float]: (event_message, fuel_delta, altitude_delta) + """ + event_chance = random.random() + + # 5-10% chance of event + if event_chance > 0.92: # 8% base chance + event_roll = random.random() + + if event_roll < 0.15: # O-ring failure + msg = "๐Ÿ”ด O-RING FAILURE! Thrust reduced to 50% this burn!" + return msg, 0, 0 + + elif event_roll < 0.35: # Meteor shower + altitude_loss = random.uniform(5, 15) + msg = f"โ˜„๏ธ METEOR SHOWER! Lost {altitude_loss:.0f} miles altitude!" + return msg, 0, -altitude_loss + + elif event_roll < 0.50: # Solar flare + msg = "โšก SOLAR FLARE! Instruments blinded for this turn..." + return msg, 0, 0 + + elif event_roll < 0.70: # Hostile aliens drain fuel + fuel_drain = random.uniform(500, 2000) + msg = f"๐Ÿ‘ฝ ALIENS! They drained {fuel_drain:.0f} lbs of fuel! ๐Ÿ˜ฑ" + return msg, -fuel_drain, 0 + + elif event_roll < 0.85: # Helpful aliens add fuel + fuel_gain = random.uniform(500, 1500) + msg = f"๐Ÿ‘ฝ ALIENS! Wait... they're helping? Gave us {fuel_gain:.0f} lbs! ๐Ÿ’š" + return msg, fuel_gain, 0 + + else: # Micro-meteorite fuel leak + fuel_leak = random.uniform(100, 500) + msg = f"๐Ÿ’ฅ MICRO-METEORITE! Hull breach! {fuel_leak:.0f} lbs fuel leaking!" + return msg, -fuel_leak, 0 + + # No event + return "", 0, 0 + + +def update_game_state( + capsule: Capsule, sim_clock: SimulationClock, burn_rate: float, burn_duration: float = 10 +) -> Tuple[bool, Optional[str]]: + """ + Update game state for one simulation cycle. + + Args: + capsule: Current capsule state + sim_clock: Current simulation clock + burn_rate: Burn rate in lbs/sec (0-200) + burn_duration: How long to burn in seconds (default 10) + + Returns: + Tuple[bool, Optional[str]]: (game_ended, outcome_message) + - game_ended: True if landed or out of fuel + - outcome_message: Result string if game ended, None otherwise + """ + + # Check for engine failure + engine_failed, new_temp = check_engine_failure(burn_rate, capsule.engine_temp, burn_duration) + capsule.engine_temp = new_temp + + if engine_failed: + return True, format_engine_failure(sim_clock, capsule) + + # Set the burn rate for this cycle + if burn_rate < 0: + return False, None # Invalid input + + # Allow overspeed burn at player's risk + capsule.fuel_per_second = burn_rate + sim_clock.time_until_next_prompt = burn_duration + + # Simulate one cycle (up to next prompt or landing) + while sim_clock.time_until_next_prompt > 0: + if capsule.is_out_of_fuel(): + outcome = show_out_of_fuel(sim_clock, capsule) + return True, outcome + + # clock advance is the shorter of the time to the next prompt, + # or when we run out of fuel. + if capsule.fuel_per_second > 0: + delta_t = min( + sim_clock.time_until_next_prompt, capsule.fuel_time_remaining() + ) + else: + delta_t = sim_clock.time_until_next_prompt + + new_state = capsule.predict_motion(delta_t) + + if new_state.altitude <= 0: + outcome = process_final_tick(delta_t, sim_clock, capsule) + return True, outcome + + if capsule.velocity > 0 and new_state.velocity < 0: + if handle_flyaway(sim_clock, capsule): + outcome = process_final_tick(delta_t, sim_clock, capsule) + return True, outcome + else: + capsule.update_state(sim_clock, delta_t, new_state) + + # If we ran out of fuel during this tick, end the turn immediately + if capsule.is_out_of_fuel() and sim_clock.time_until_next_prompt > 0: + sim_clock.time_until_next_prompt = 0 + + # Simulation cycle complete, get ready for next prompt + return False, None + + +def get_status_message(capsule: Capsule, sim_clock: SimulationClock, use_metric: bool = False) -> str: + """Get current game status as a formatted string for display""" + return capsule.make_state_display_string(sim_clock, use_metric) + + +class LunarLander: + """Mesh Bot compatible Lunar Lander game handler""" + + def __init__(self): + """Initialize the game""" + pass + + def new_game(self) -> Tuple[Dict, str]: + """ + Start a new game with randomized difficulty. + + Returns: + Tuple[Dict, str]: (game_state_dict, welcome_message) + """ + # Randomize starting conditions for variety + # Altitude: mostly mid-range but can be high or low + altitude = random.uniform(60, 180) + + # Velocity: usually gentle descent, occasionally steeper + # Biased toward easier starts for fun gameplay + # NOTE: Must convert mph to miles/second for internal physics! + velocity_mph = random.choices( + [random.uniform(0.3, 0.8), random.uniform(0.8, 1.5)], + weights=[70, 30] # 70% gentle, 30% challenging + )[0] + velocity = velocity_mph / 3600 # Convert mph to miles/second + + # Fuel: mostly normal with occasional scarcity + # 0.8-1.3 multiplier gives good playability range + # Biased toward having enough fuel + fuel_mult = random.choices( + [random.uniform(0.85, 1.15), random.uniform(0.75, 0.95)], + weights=[80, 20] # 80% normal+ fuel, 20% tight fuel + )[0] + + mass_with_fuel = 16500 + (16500 * fuel_mult) + + capsule = Capsule( + altitude=altitude, + velocity=velocity, + m=mass_with_fuel, + n=16500, # Base mass stays constant + ) + sim_clock = SimulationClock(0, 10) + + game_state = { + "capsule": capsule.to_dict(), + "clock": sim_clock.to_dict(), + "turn_count": 0, + "result": None, + } + + welcome = ( + "๐Ÿš€ LUNAR LANDER ๐Ÿš€\n" + "Your onboard computer crashed (Boeing made it ๐Ÿ˜ฌ)\n" + "YOU must land this capsule manually!\n\n" + "๐Ÿ’ก Enter: burn_rate [duration_sec]\n" + "๐Ÿ“Š Examples: '100' (10s default) or '150 5'\n" + "โฑ๏ธ Watch descent & fuel! Type 'help' for more\n\n" + ) + + status = get_status_message(capsule, sim_clock, use_metric=False) + return game_state, welcome + status + "\n\nโ†’ Enter burn rate:" + + def play(self, game_state: Dict, burn_input: str, use_metric: bool = False) -> Tuple[Dict, str, bool]: + """ + Process player input and return updated state. + + Args: + game_state: Current game state dict + burn_input: Player input (burn rate or command) + use_metric: If True, display in metric units (km, m/s); if False, imperial (mi, ft, mph) + + Returns: + Tuple[Dict, str, bool]: (updated_state, response_message, game_ended) + """ + burn_rate = None + burn_duration = None + + # Check if confirming overheat warning + if 'pending_burn' in game_state: + burn_input_lower = burn_input.strip().lower() + if burn_input_lower in ('y', 'yes'): + burn_rate, burn_duration = game_state['pending_burn'] + del game_state['pending_burn'] + # Proceed with high burn - skip to execution below + elif burn_input_lower in ('n', 'no'): + del game_state['pending_burn'] + capsule = Capsule.from_dict(game_state['capsule']) + sim_clock = SimulationClock.from_dict(game_state['clock']) + msg = "โŒ Burn cancelled. Engine temp cooling...\n\n" + msg += get_status_message(capsule, sim_clock, use_metric) + msg += "\n\nโ†’ Enter burn rate:" + return (game_state, msg, False) + else: + return (game_state, "Enter 'y' to proceed or 'n' to cancel:", False) + + # Only parse if we don't already have burn_rate from pending_burn confirmation + if burn_rate is None: + # Handle commands + burn_input = burn_input.strip().lower() + if burn_input in ("quit", "end", "exit"): + return game_state, "๐Ÿš€ Mission aborted. Safe travels! Type 'lunarlander' to try again.", True + if burn_input in ("help", "?", "h"): + help_msg = ( + "๐Ÿ“– CONTROLS:\n" + "โ€ข Burn rate: 0-300 lbs/sec\n" + "โ€ข Duration: 1-240 seconds (default 10)\n" + "โ€ข Format: '100' or '100 5'\n\n" + "Examples:\n" + " 100 โ†’ Burn at 100 for 10s (default)" + " 150 5 โ†’ Burn at 150 for 5s" + " 0 โ†’ FREE FALL (no burn)\n\n" + "๐Ÿ’ก Quick burn = less fuel, longer = more control!" + ) + return (game_state, help_msg, False) + + # Parse burn rate and optional duration + burn_rate = 0 + burn_duration = 10 # default + + try: + parts = burn_input.split() + burn_rate = float(parts[0]) + if len(parts) > 1: + burn_duration = float(parts[1]) + + # Validate burn rate (allow > 300 but warn about risk) + if burn_rate < 0: + capsule = Capsule.from_dict(game_state['capsule']) + sim_clock = SimulationClock.from_dict(game_state['clock']) + error_msg = f"โŒ Burn rate cannot be negative!\nUse 0-300 safe, higher = risky!\n\n" + error_msg += get_status_message(capsule, sim_clock, use_metric) + error_msg += "\n\nโ†’ Try again (e.g., '100' or '100 5'):" + return (game_state, error_msg, False) + + if burn_rate > 300: + # Warn about overheat risk + capsule = Capsule.from_dict(game_state['capsule']) + sim_clock = SimulationClock.from_dict(game_state['clock']) + warn_msg = f"โš ๏ธ CAUTION: Burn rate {burn_rate} exceeds safe limit (300)!\n" + warn_msg += f"๐Ÿ”ฅ Engine temp will rise rapidly - risk of failure!\n\n" + warn_msg += get_status_message(capsule, sim_clock, use_metric) + warn_msg += f"\n\nโ†’ Proceed with {burn_rate} lbs/sec? (y/n):" + # Store pending action + game_state['pending_burn'] = (burn_rate, burn_duration) + return (game_state, warn_msg, False) + + # Validate duration + if burn_duration < 1 or burn_duration > 240: + capsule = Capsule.from_dict(game_state['capsule']) + sim_clock = SimulationClock.from_dict(game_state['clock']) + error_msg = f"โŒ Duration '{burn_duration}' out of range!\nUse 1-240 seconds (default 10)\n\n" + error_msg += get_status_message(capsule, sim_clock, use_metric) + error_msg += "\n\nโ†’ Try again (e.g., '100' or '100 5'):" + return (game_state, error_msg, False) + + except (ValueError, IndexError): + capsule = Capsule.from_dict(game_state['capsule']) + sim_clock = SimulationClock.from_dict(game_state['clock']) + error_msg = f"โŒ '{burn_input}' invalid format!\nEnter: burn_rate [duration]\n\n" + error_msg += get_status_message(capsule, sim_clock, use_metric) + error_msg += "\n\nโ†’ Try again (e.g., '100' or '100 5'):" + return (game_state, error_msg, False) + + # Restore game state + capsule = Capsule.from_dict(game_state["capsule"]) + sim_clock = SimulationClock.from_dict(game_state["clock"]) + + # Check for random space events (5-10% chance) + event_msg, fuel_delta, altitude_delta = trigger_random_event(capsule, sim_clock) + + # Apply event effects + if event_msg: + capsule.m = max(capsule.n, capsule.m + fuel_delta) # Cap at base mass minimum + capsule.altitude = max(0, capsule.altitude + altitude_delta) + + # Check for O-ring failure - reduce thrust + if "O-RING" in event_msg: + burn_rate = burn_rate * 0.5 + + # Update game with burn rate and duration + game_ended, outcome = update_game_state(capsule, sim_clock, burn_rate, burn_duration) + + # Save updated state + game_state["capsule"] = capsule.to_dict() + game_state["clock"] = sim_clock.to_dict() + game_state["turn_count"] += 1 + game_state["result"] = outcome if game_ended else None + + # Generate response + if game_ended: + response = outcome + "\n\n๐Ÿ”„ Type 'lunarlander' to play again" + else: + event_display = event_msg + "\n\n" if event_msg else "" + status = get_status_message(capsule, sim_clock, use_metric) + response = event_display + status + "\n\nโ†’ Next burn rate:" + + return game_state, response, game_ended + + +def run_simulation() -> None: + """Legacy function for standalone play - kept for backwards compatibility""" + print() + print( + format_line_for_report("SEC", "MI", "FT", "MPH", "LB FUEL", "BURN RATE", True) + ) + print("(This is a legacy mode - use mesh_bot.py for mesh integration)") + + +def main() -> None: + """Legacy main for standalone play""" + print("LUNAR") + print("CREATIVE COMPUTING MORRISTOWN, NEW JERSEY\n\n\n") + print("THIS IS A COMPUTER SIMULATION OF AN APOLLO LUNAR") + print("LANDING CAPSULE.\n\n") + print("THE ON-BOARD COMPUTER HAS FAILED (IT WAS MADE BY") + print("XEROX) SO YOU HAVE TO LAND THE CAPSULE MANUALLY.\n") + print("SET BURN RATE OF RETRO ROCKETS TO ANY VALUE BETWEEN") + print("0 (FREE FALL) AND 300 (MAXIMUM BURN) POUNDS PER SECOND.") + print("SET NEW BURN RATE EVERY 10 SECONDS.\n") + print("CAPSULE WEIGHT 32,500 LBS; FUEL WEIGHT 16,000 LBS.\n\n\n") + print("GOOD LUCK\n") + print("(Converted to mesh bot - see mesh_bot.py for integration)") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/modules/games/potatogunner.py b/modules/games/potatogunner.py new file mode 100644 index 0000000..66f0af9 --- /dev/null +++ b/modules/games/potatogunner.py @@ -0,0 +1,755 @@ +#!/usr/bin/env python3 +# Potato Gunner - Potato-Themed Artillery Game for MeshBot +# Based on: https://github.com/coding-horror/basic-computer-games/blob/main/42_Gunner/python/gunner.py +# 2026 Kelly Keeton K7MHI + +from math import sin, cos, radians +from random import random, randint, choice +import time +import pickle +from modules.log import logger + + +def saveHSPotato(nodeID, highScore): + """Save the game high_score to pickle.""" + highScore = {'nodeID': nodeID, 'highScore': highScore} + try: + with open('data/potatogunner_hs.pkl', 'wb') as file: + pickle.dump(highScore, file) + except FileNotFoundError: + logger.debug("System: PotatoGunner: Creating new data/potatogunner_hs.pkl file") + with open('data/potatogunner_hs.pkl', 'wb') as file: + pickle.dump(highScore, file) + + +def loadHSPotato(): + """Load the game high_score from pickle.""" + try: + with open('data/potatogunner_hs.pkl', 'rb') as file: + highScore = pickle.load(file) + return highScore + except FileNotFoundError: + logger.debug("System: PotatoGunner: Creating new data/potatogunner_hs.pkl file") + highScore = {'nodeID': 0, 'highScore': 0} + with open('data/potatogunner_hs.pkl', 'wb') as file: + pickle.dump(highScore, file) + return highScore + + +class PotatoGunner: + """ + Silly potato-themed artillery game. Players fire spuds at moving targets + with wind, chaos events, and powerups. Multi-round tournament format. + """ + + def __init__(self): + self.games = {} # {nodeID: game_state_dict} + + @staticmethod + def format_yards(value): + """Format yardage with comma separators for readability.""" + return f"{int(value):,d}" + + def new_game(self, nodeID, difficulty='normal'): + """Start a new tournament session.""" + # Load existing highscore + hs_data = loadHSPotato() + high_score = hs_data.get('highScore', 0) + + self.games[nodeID] = { + 'round': 1, + 'score': 0, + 'rounds_won': 0, + 'total_shots': 0, + 'total_hits': 0, + 'accuracy_streak': 0, + 'powerups': { + 'super_spud': 0, + 'homing_spud': 0, + 'lucky_potato': 0, + 'potato_shield': 0 + }, + 'powerups_used': { + 'super_spud': 0, + 'homing_spud': 0, + 'lucky_potato': 0, + 'potato_shield': 0 + }, + 'difficulty': difficulty, + 'chaos_event': None, + 'last_accuracy': 0.0, + 'start_time': time.time(), + 'high_score': high_score + } + + # intro + intro = f"๐Ÿ† High Score: {high_score} pts\n" + intro += "๐Ÿ’ฃ Fire spuds at targets!\n" + intro += " Angle: 0-90ยฐ | Power: 0-100 PSI\n" + intro += " Commands: hint, stats, exit\n" + + return intro + self._display_round_intro(nodeID) + + def play(self, nodeID, input_msg): + """Process a single shot/action in the game.""" + try: + if nodeID not in self.games: + return self.new_game(nodeID) + + g = self.games[nodeID] + input_str = input_msg.strip().lower() + + # Handle quit commands + if input_str in ('end', 'e', 'quit', 'q', 'exit'): + return self.end(nodeID) + + # Handle stats command + if input_str in ('stats', 's', 'score'): + return self._display_stats(nodeID) + + # Handle hint command + if input_str in ('hint', 'h', 'help', 'examples'): + return self._show_launch_hints(nodeID) + elevation = None + power = 50 # Default power if not specified + + hints = { + 'low': 15, 'min': 10, 'ground': 5, + 'mid': 45, 'medium': 45, 'normal': 45, + 'high': 70, 'max': 80, 'steep': 85 + } + + try: + # Check if format is "angle,power" (numeric or hint word) + if ',' in input_str: + parts = input_str.split(',') + if len(parts) != 2: + return "โš ๏ธ Format: elevation,power (e.g., 45,60 or high,30)" + + # Try numeric first, then hint words + elevation_str = parts[0].strip() + try: + elevation = float(elevation_str) + except ValueError: + # Check if it's a hint word + if elevation_str in hints: + elevation = hints[elevation_str] + else: + return f"โš ๏ธ '{elevation_str}' not recognized. Use angle (0-90) or hint: {', '.join(hints.keys())}" + + try: + power = float(parts[1].strip()) + except ValueError: + return "โš ๏ธ Power must be 0-100 PSI (e.g., high,30)" + else: + # Just elevation (no power specified) + try: + elevation = float(input_str) + except ValueError: + # Check if it's a hint word + if input_str in hints: + elevation = hints[input_str] + power = 50 # Default power + else: + return "โš ๏ธ Enter: angle (0-90ยฐ) or angle,power (e.g., 45,60)\nHints: low, mid, high, max\nCommands: hint, stats, e" + else: + power = 50 # Default power if numeric angle only + + # Validate elevation + if elevation < 0 or elevation > 90: + return "โš ๏ธ Elevation must be 0-90 degrees. Try again!" + + # Validate power + if power < 0 or power > 100: + return "โš ๏ธ Power must be 0-100 PSI. Try again!" + + except ValueError: + return "โš ๏ธ Error parsing input. Use: angle,power (e.g., 45,60 or high,30)" + + # Process the shot + return self._fire_spud(nodeID, elevation, power) + + except Exception as e: + return f"โš ๏ธ Error: {e}" + + def _fire_spud(self, nodeID, elevation, power=50): + """Fire a spud at the target and calculate hit.""" + g = self.games[nodeID] + + # Get/create round state if needed + if 'current_round_state' not in g: + g['current_round_state'] = self._generate_round_state(nodeID) + + rs = g['current_round_state'] + g['total_shots'] += 1 + + # Enhanced physics: calculate spud distance with better ballistics + # Using realistic projectile motion: range = (vโ‚€ยฒ * sin(2ฮธ)) / g + # Power scales from 0-100 PSI, affecting velocity + elevation_rad = radians(elevation) + + # Base distance calculation with power scaling + gravity = 9.81 + # Scale velocity based on power (0-100 PSI) where 50 PSI = base 100 m/s + # Formula: velocity = (power / 50) * 100, so 50 PSI = 100 m/s, 100 PSI = 200 m/s, 10 PSI = 20 m/s + base_velocity = (power / 50.0) * 100 # m/s (scales with PSI) + base_distance = (base_velocity ** 2 * sin(2 * elevation_rad)) / gravity + base_distance = base_distance * 100 # Convert to yards (roughly) + + # Apply chaos effects to the trajectory + wind_factor = rs.get('wind', 0) + + # Check if Lucky Potato should be used (automatic on chaos survival) + lucky_active = False + if rs['chaos_event'] and g['powerups']['lucky_potato'] > 0: + # Auto-use lucky potato to skip chaos effect + g['powerups']['lucky_potato'] -= 1 + g['powerups_used']['lucky_potato'] += 1 + lucky_active = True + wind_factor = 0 # Nullify chaos effect + else: + # Handle specific chaos events with their effects + if rs['chaos_event'] == 'earthquake': + wind_factor += randint(-100, 100) # Adds randomness + elif rs['chaos_event'] == 'inverse_wind': + wind_factor = -wind_factor + elif rs['chaos_event'] == 'ricochet': + # Spud might bounce unexpectedly + ricochet_chance = random() + if ricochet_chance > 0.7: # 30% chance of crazy ricochet + base_distance += randint(-500, 500) + elif rs['chaos_event'] == 'gravity_flip': + # Inverted gravity means opposite trajectory behavior + base_distance = base_distance * -0.5 + + actual_distance = base_distance + wind_factor + actual_distance = max(0, actual_distance) # Can't be negative + + # Apply powerup: homing spud (auto-correction toward target) + powerup_correction = 0 + if g['powerups']['homing_spud'] > 0: + powerup_correction = (rs['target_distance'] - actual_distance) * 0.15 # 15% auto-correct + actual_distance += powerup_correction + + # Calculate proximity and accuracy + proximity = abs(actual_distance - rs['target_distance']) + + # Accuracy radius scales with powerups and difficulty + base_accuracy_radius = 670 # Yards + if g['powerups']['super_spud'] > 0: + base_accuracy_radius += 60 # +60 accuracy improvement + if rs['chaos_event'] and not lucky_active: + base_accuracy_radius -= 30 # Chaos makes it harder (unless Lucky saved you) + + accuracy_radius = max(50, base_accuracy_radius) # Min 50 yard radius + + # Build response message + msg = f"๐Ÿฅ”FIRED AT๐Ÿš€ {elevation}ยฐ with {int(power)} PSI\n" + msg += f"Distance calc: {self.format_yards(base_distance)}yd" + if powerup_correction != 0: + msg += f" (homing: {int(powerup_correction):+d}yd)" + msg += "\n" + msg += f"Velocity: {int(base_velocity)}m/s | Wind effect: {int(wind_factor):+d}yd\n" + msg += f"Actual distance: {self.format_yards(actual_distance)}yd | Target: {self.format_yards(rs['target_distance'])}yd\n" + msg += f"Missed by: {self.format_yards(proximity)}yd\n" + + # Add chaos event narrative if present + if rs['chaos_event']: + chaos_info = self._get_chaos_description(rs['chaos_event']) + msg += f"\n{chaos_info['title']} {chaos_info['flavor']}\n" + if lucky_active: + msg += "๐Ÿ€ Lucky Potato activated! Chaos effect negated!\n" + + msg += "\n" + + # Determine hit or miss + if proximity <= accuracy_radius: + # HIT! + msg += "๐ŸŽฏ DIRECT HIT! ๐Ÿฅ”๐Ÿ’ฅ\n" + g['total_hits'] += 1 + g['rounds_won'] += 1 + g['accuracy_streak'] += 1 + + # Calculate points with multiple modifiers + accuracy_bonus = max(0, int((accuracy_radius - proximity) / accuracy_radius * 100)) + combo_bonus = min(75, g['accuracy_streak'] * 15) # +15 per streak, max 75 + round_multiplier = 1.0 + (g['round'] - 1) * 0.15 # 15% per round + + powerup_bonus = 0 + if g['powerups']['super_spud'] > 0: + powerup_bonus += 50 # Increased from 25 + g['powerups']['super_spud'] -= 1 + g['powerups_used']['super_spud'] += 1 + msg += "โšก Super Spud activated!\n" + + if g['powerups']['homing_spud'] > 0: + powerup_bonus += 30 + g['powerups']['homing_spud'] -= 1 + g['powerups_used']['homing_spud'] += 1 + msg += "๐ŸŽฏ Homing Spud guided you to victory!\n" + + chaos_bonus = 0 + if rs['chaos_event']: + chaos_bonus = 60 # Higher bonus for chaos survival + chaos_name = rs['chaos_event'].replace('_', ' ').title() + msg += f"๐ŸŽŠ Survived chaos ({chaos_name}): +{chaos_bonus} bonus!\n" + + # Calculate total points + base_points = 100 + total_points = int((base_points + accuracy_bonus + combo_bonus + powerup_bonus + chaos_bonus) * round_multiplier) + + g['score'] += total_points + g['last_accuracy'] = min(100, (accuracy_radius - proximity)) / accuracy_radius + + msg += f"\nโœจ +{total_points} points!\n" + msg += f" Base: {base_points} | Accuracy: +{accuracy_bonus} | " + msg += f"Streak: +{combo_bonus} | Roundร—{round_multiplier:.2f}\n" + + # Award powerups on successful hit + powerup_msg = self._award_powerups(nodeID) + if powerup_msg: + msg += f"\n{powerup_msg}" + + msg += f"\n๐Ÿ“Š Session: {g['score']} pts | Accuracy: {g['total_hits']}/{g['total_shots']} " + msg += f"({100*g['total_hits']//max(1, g['total_shots'])}%)" + else: + # MISS + msg += "โŒ MISS! โš ๏ธ\n" + if actual_distance < rs['target_distance']: + msg += f"Short by {self.format_yards(proximity)} yards\n" + else: + msg += f"Over by {self.format_yards(proximity)} yards\n" + + g['accuracy_streak'] = 0 + + msg += f"\n๐Ÿ“Š Session: {g['score']} pts | Accuracy: {g['total_hits']}/{g['total_shots']} " + msg += f"({100*g['total_hits']//max(1, g['total_shots'])}%)" + + # Clear round state and auto-advance to next round display + del g['current_round_state'] + msg += "\n" + self._display_round_intro(nodeID) + + return msg + + def _award_powerups(self, nodeID): + """Determine and award powerups on successful hit based on performance.""" + g = self.games[nodeID] + msg = "" + awarded = [] + + # Accuracy-based powerups + if g['last_accuracy'] >= 0.75: # 75% accuracy or better + if g['total_hits'] > 0: # Make sure we have actual hits + g['powerups']['homing_spud'] += 1 + awarded.append("๐ŸŽฏ Homing Spud (auto-aim next round)") + + if g['last_accuracy'] >= 0.50: # 50% accuracy or better + if g['total_hits'] > 0: + g['powerups']['super_spud'] += 1 + awarded.append("โšก Super Spud (+40% accuracy)") + + # Streak-based powerup + if g['accuracy_streak'] >= 3: + g['powerups']['potato_shield'] = min(2, g['powerups'].get('potato_shield', 0) + 1) + awarded.append("๐Ÿ›ก๏ธ Potato Shield (free miss next round)") + + # Chaos survival powerup + if g.get('current_round_state', {}).get('chaos_event'): + # 20% chance to get lucky potato on chaos survival + if random() < 0.2: + g['powerups']['lucky_potato'] = min(1, g['powerups'].get('lucky_potato', 0) + 1) + awarded.append("๐Ÿ€ Lucky Potato (skip chaos)") + + if awarded: + msg = "\n๐ŸŽ Powerups Earned:\n" + for pup in awarded: + msg += f" โ€ข {pup}\n" + + return msg + + def _get_chaos_description(self, chaos_event): + """Get detailed silly description for chaos events.""" + descriptions = { + 'wind_gust': { + 'title': '๐ŸŒช๏ธ WIND GUST!', + 'flavor': 'A sudden gust catches your spud mid-flight!', + 'effect': 'Trajectory unpredictable!', + }, + 'earthquake': { + 'title': '๐ŸŒ EARTHQUAKE!', + 'flavor': 'The ground shakes violently beneath the artillery range!', + 'effect': 'Everything shifts unexpectedly!', + }, + 'rain': { + 'title': '๐ŸŒง๏ธ HEAVY RAIN!', + 'flavor': 'Torrential downpour obscures the target!', + 'effect': 'Visibility severely reduced!', + }, + 'meteor': { + 'title': 'โ˜„๏ธ METEOR SHOWER!', + 'flavor': 'Meteorites crash nearby, creating shockwaves!', + 'effect': 'Field distorted by impacts!', + }, + 'alien': { + 'title': '๐Ÿ‘ฝ ALIEN ABDUCTION!', + 'flavor': 'A UFO hovers overhead, messing with gravity!', + 'effect': 'Physics go haywire!', + }, + 'squirrel': { + 'title': '๐Ÿฟ๏ธ SQUIRREL ATTACK!', + 'flavor': 'A mischievous squirrel steals a spud from the launcher!', + 'effect': 'Target distracted but launcher disrupted!', + }, + 'birds': { + 'title': '๐Ÿฆ… BIRD STRIKE!', + 'flavor': 'A flock of birds interferes with your trajectory!', + 'effect': 'Unpredictable interference patterns!', + }, + 'farmer': { + 'title': '๐Ÿ‘จโ€๐ŸŒพ FARMER IN FIELD!', + 'flavor': 'The farmer wanders into the firing zone!', + 'effect': 'Must avoid hitting him! (Or get penalty)', + }, + 'gravity_flip': { + 'title': 'โฌ†๏ธ GRAVITY FLIPPED!', + 'flavor': 'A mysterious force inverts gravity in the field!', + 'effect': 'Spuds fly in weird trajectories!', + }, + 'inverse_wind': { + 'title': '๐Ÿ’จ WIND REVERSAL!', + 'flavor': 'The wind suddenly switches direction!', + 'effect': 'Expect opposite effects!', + }, + 'ricochet': { + 'title': '๐Ÿ”€ RICOCHET MODE!', + 'flavor': 'Mysterious bouncy energy permeates the range!', + 'effect': 'Spuds might bounce unpredictably!', + }, + 'slowmo': { + 'title': '๐ŸŒ SLOW-MOTION!', + 'flavor': 'Time slows down mysteriously...', + 'effect': 'Everything moves in slow-mo!', + }, + } + + return descriptions.get(chaos_event, { + 'title': 'โšก CHAOS!', + 'flavor': 'Something weird is happening!', + 'effect': 'Unknown chaos detected!', + }) + + def _generate_round_state(self, nodeID): + """Generate round difficulty, chaos event, target, etc. with scaling difficulty.""" + g = self.games[nodeID] + round_num = g['round'] + score = g['score'] + + # Dynamic difficulty based on round AND score + # Early rounds are easier, later rounds harder + if round_num <= 2: + base_distance_range = (28000, 38000) + wind_max = 15 + chaos_chance = 0.20 + chaos_intensity = 'gentle' + elif round_num <= 4: + base_distance_range = (22000, 38000) + wind_max = 30 + chaos_chance = 0.35 + chaos_intensity = 'moderate' + elif round_num <= 6: + base_distance_range = (18000, 40000) + wind_max = 40 + chaos_chance = 0.45 + chaos_intensity = 'wild' + else: + base_distance_range = (15000, 45000) + wind_max = 50 + (round_num - 6) * 5 # Escalate further + chaos_chance = 0.55 + (round_num - 6) * 0.05 + chaos_intensity = 'insane' + + # If player is doing well, bump up difficulty slightly + if score > 500: + wind_max += 10 + chaos_chance += 0.05 + if score > 1500: + wind_max += 15 + chaos_chance += 0.10 + + base_distance = randint(base_distance_range[0], base_distance_range[1]) + + # More realistic wind: comes in gusts (can be positive or negative) + wind_bias = (random() - 0.5) * 2 # Bias wind -1 to +1 + wind_magnitude = random() * wind_max + wind_factor = wind_bias * wind_magnitude + + # Chaos event selection - weighted based on intensity + chaos_event = None + if random() < chaos_chance: + if chaos_intensity == 'gentle': + # Mostly environmental, not too harsh + chaos_event = choice(['wind_gust', 'rain', 'birds']) + elif chaos_intensity == 'moderate': + # Mix of environmental and character + chaos_event = choice([ + 'wind_gust', 'rain', 'birds', 'squirrel', 'earthquake', 'farmer' + ]) + elif chaos_intensity == 'wild': + # Include rule mutations + chaos_event = choice([ + 'wind_gust', 'earthquake', 'rain', 'alien', + 'squirrel', 'birds', 'farmer', + 'gravity_flip', 'inverse_wind', 'ricochet' + ]) + else: # insane + # All chaos types available + chaos_event = choice([ + 'wind_gust', 'earthquake', 'rain', 'meteor', 'alien', + 'squirrel', 'birds', 'farmer', + 'gravity_flip', 'inverse_wind', 'ricochet', 'slowmo' + ]) + + # Apply chaos event effects + if chaos_event == 'wind_gust': + wind_factor *= 2.5 # Sudden wind increase + elif chaos_event == 'earthquake': + base_distance += randint(-1000, 1000) + wind_factor += randint(-50, 50) + elif chaos_event == 'rain': + # Rain adds unpredictability + wind_factor += (random() - 0.5) * 200 + elif chaos_event == 'meteor': + # Meteor strikes can create shockwaves + base_distance += randint(-2000, 2000) + wind_factor *= 1.5 + elif chaos_event == 'inverse_wind': + wind_factor = -wind_factor + elif chaos_event == 'gravity_flip': + # Reduced effectiveness for gravity flip + pass # Applied in _fire_spud + elif chaos_event == 'ricochet': + # Ricochet is handled in _fire_spud + pass + + # Ensure distance remains positive + base_distance = max(5000, base_distance) + + return { + 'target_distance': base_distance, + 'wind': wind_factor, + 'chaos_event': chaos_event, + 'chaos_intensity': chaos_intensity, + 'created_at': time.time() + } + + def _start_round(self, nodeID): + """Start a new round.""" + g = self.games[nodeID] + g['round'] += 1 + if 'current_round_state' in g: + del g['current_round_state'] + return self._display_round_intro(nodeID) + + def _display_round_intro(self, nodeID): + """Display introduction to a round with detailed info.""" + g = self.games[nodeID] + rs = self._generate_round_state(nodeID) + g['current_round_state'] = rs + + msg = f"\n๐Ÿฅ” ROUND {g['round']} ๐Ÿฅ”\n" + msg += f"Score: {g['score']} | Wins: {g['rounds_won']}/{g['round']}\n" + + # Calculate and display stats + if g['total_shots'] > 0: + accuracy_pct = 100 * g['total_hits'] // g['total_shots'] + msg += f"Hit Rate: {accuracy_pct}%" + if g['accuracy_streak'] > 0: + msg += f" | ๐Ÿ”ฅ Streak: {g['accuracy_streak']}" + msg += "\n\n" + + # Target info + msg += f"๐ŸŽฏ Target: ~{self.format_yards(rs['target_distance'])}yd\n" + if rs['wind'] != 0: + msg += f"๐Ÿ’จ Wind: {int(rs['wind']):+d}yd\n" + + # Chaos event announcement + if rs['chaos_event']: + chaos_info = self._get_chaos_description(rs['chaos_event']) + msg += f"\nโš ๏ธ {chaos_info['title']} โš ๏ธ\n" + msg += f" {chaos_info['flavor']}\n" + msg += f" โ†’ {chaos_info['effect']}\n" + + pups = [] + if g['powerups']['super_spud'] > 0: + pups.append(f" โšก Super Spud ร—{g['powerups']['super_spud']}") + if g['powerups']['homing_spud'] > 0: + pups.append(f" ๐ŸŽฏ Homing ร—{g['powerups']['homing_spud']}") + if g['powerups']['lucky_potato'] > 0: + pups.append(f" ๐Ÿ€ Lucky ร—{g['powerups']['lucky_potato']}") + if g['powerups']['potato_shield'] > 0: + pups.append(f" ๐Ÿ›ก๏ธ Shield ร—{g['powerups']['potato_shield']}") + + if pups: + msg += f"\n๐Ÿ’ช Powerups:\n" + msg += "\n".join(pups) + "\n" + + msg += "\n๐Ÿ”ซ Angle,Power?" + return msg + + def _display_stats(self, nodeID): + """Display current session statistics.""" + if nodeID not in self.games: + return "No active game session." + + g = self.games[nodeID] + accuracy_pct = 100 * g['total_hits'] // max(1, g['total_shots']) + + msg = "๐Ÿ“Š STATS ๐Ÿ“Š\n" + msg += f"Round: {g['round']} | Score: {g['score']} | Wins: {g['rounds_won']}/{g['round']}\n" + msg += f"Shots: {g['total_shots']} | Hits: {g['total_hits']} ({accuracy_pct}%) | Streak: {g['accuracy_streak']}\n" + + + if (g['powerups']['super_spud'] + g['powerups']['homing_spud'] + + g['powerups']['lucky_potato'] + g['powerups']['potato_shield']) > 0: + msg += "๐Ÿ’ช Active Powerups:\n" + if g['powerups']['super_spud'] > 0: + msg += f" โšก Super Spud ร—{g['powerups']['super_spud']}\n" + if g['powerups']['homing_spud'] > 0: + msg += f" ๐ŸŽฏ Homing ร—{g['powerups']['homing_spud']}\n" + if g['powerups']['lucky_potato'] > 0: + msg += f" ๐Ÿ€ Lucky ร—{g['powerups']['lucky_potato']}\n" + if g['powerups']['potato_shield'] > 0: + msg += f" ๐Ÿ›ก๏ธ Shield ร—{g['powerups']['potato_shield']}\n" + + msg += "\nFire away! Angle,Power (e.g., 45,60)" + return msg + + def _show_launch_hints(self, nodeID): + """Show example launches with different angle/power combinations.""" + if nodeID not in self.games: + return "No active game session." + + g = self.games[nodeID] + rs = g.get('current_round_state') + if not rs: + rs = self._generate_round_state(nodeID) + + target = rs['target_distance'] + + msg = "๐ŸŽฏ **LAUNCH EXAMPLES** ๐ŸŽฏ\n" + if rs['wind'] != 0: + msg += f"Target: {self.format_yards(target)}yd | Wind: {int(rs['wind']):+d}yd\n\n" + else: + msg += f"Target: {self.format_yards(target)}yd\n\n" + + # Calculate 3 example launches with different strategies + examples = [ + ("Low & Slow", 15, 30), + ("Mid Balanced", 45, 50), + ("High & Fast", 70, 80), + ] + + for name, angle, power in examples: + # Quick physics calculation + elevation_rad = radians(angle) + gravity = 9.81 + base_velocity = (power / 50.0) * 100 + base_distance = (base_velocity ** 2 * sin(2 * elevation_rad)) / gravity + base_distance = base_distance * 100 + wind_factor = rs.get('wind', 0) + actual_distance = base_distance + wind_factor + actual_distance = max(0, actual_distance) + proximity = abs(actual_distance - target) + + # Show result + accuracy_emoji = "๐ŸŽฏ" if proximity < 500 else "โšก" if proximity < 1000 else "๐Ÿ“" + msg += f"{accuracy_emoji} {name}: {angle}ยฐ,{power}PSI โ†’ {self.format_yards(actual_distance)}yd" + if proximity < 500: + msg += " (Close!)" + msg += "\n" + + msg += "\nTry: 45,60 or high,40 or just low" + return msg + + def end(self, nodeID): + """End the game and display final score.""" + if nodeID not in self.games: + return "No active game." + + g = self.games[nodeID] + + # Calculate performance rating + accuracy_pct = (100 * g['total_hits']) // max(1, g['total_shots']) + if accuracy_pct >= 75: + rating = "๐Ÿ† SHARPSHOOTER ๐Ÿ†" + elif accuracy_pct >= 60: + rating = "โญ EXPERT โญ" + elif accuracy_pct >= 45: + rating = "๐Ÿ‘ DECENT ๐Ÿ‘" + elif accuracy_pct >= 30: + rating = "๐Ÿฅ” SPUD-SLINGER ๐Ÿฅ”" + else: + rating = "๐ŸŽช CHAOS CREATOR ๐ŸŽช" + + # Check if new highscore + is_high_score = g['score'] > g['high_score'] + if is_high_score: + saveHSPotato(nodeID, g['score']) + + msg = "\n๐Ÿ TOURNAMENT COMPLETE! ๐Ÿ\n" + msg += f"Rating: {rating}\n\n" + msg += "๐Ÿ“Š FINAL STATISTICS:\n" + msg += f" Final Score: {g['score']} pts" + if is_high_score: + msg += " ๐ŸŽ‰ NEW HIGH SCORE! ๐ŸŽ‰" + elif g['high_score'] > 0: + msg += f" (High Score: {g['high_score']})" + msg += "\n" + msg += f" Rounds: {g['round']} completed\n" + msg += f" Wins: {g['rounds_won']}/{g['round']} rounds ({100*g['rounds_won']//max(1, g['round'])}%)\n" + msg += f" Accuracy: {g['total_hits']}/{g['total_shots']} ({accuracy_pct}%)\n" + msg += f" Longest Streak: {g['accuracy_streak']} hits\n\n" + + # Powerup usage stats + used_any = any(g['powerups_used'].values()) + if used_any: + msg += "๐Ÿ’ช POWERUPS USED:\n" + if g['powerups_used']['super_spud'] > 0: + msg += f" โšก Super Spuds: {g['powerups_used']['super_spud']}x\n" + if g['powerups_used']['homing_spud'] > 0: + msg += f" ๐ŸŽฏ Homing Spuds: {g['powerups_used']['homing_spud']}x\n" + if g['powerups_used']['lucky_potato'] > 0: + msg += f" ๐Ÿ€ Lucky Potatoes: {g['powerups_used']['lucky_potato']}x\n" + if g['powerups_used']['potato_shield'] > 0: + msg += f" ๐Ÿ›ก๏ธ Shields: {g['powerups_used']['potato_shield']}x\n" + msg += "\n" + + # Fun facts + msg += "๐Ÿ“ˆ SESSION HIGHLIGHTS:\n" + if g['score'] > 2000: + msg += " ๐ŸŒŸ LEGENDARY PERFORMANCE!\n" + elif g['score'] > 1500: + msg += " ๐ŸŽŠ IMPRESSIVE WORK!\n" + elif g['score'] > 1000: + msg += " ๐Ÿ‘ SOLID EFFORT!\n" + else: + msg += " ๐ŸŒฑ PRACTICE MAKES PERFECT!\n" + + if accuracy_pct >= 75: + msg += " ๐Ÿ’ฏ EXCEPTIONAL ACCURACY!\n" + if g['accuracy_streak'] >= 5: + msg += f" ๐Ÿ”ฅ HOT HAND: {g['accuracy_streak']} HIT STREAK!\n" + if g['round'] >= 10: + msg += " ๐ŸŽฏ WENT THE DISTANCE!\n" + + msg += "\n๐Ÿฅ” Thanks for playing! Type 'potato' to play again." + + # Clean up + if nodeID in self.games: + del self.games[nodeID] + + return msg + + +# Singleton instance for bot integration +potatogunner = PotatoGunner() + diff --git a/modules/settings.py b/modules/settings.py index 6b9aa08..459d329 100644 --- a/modules/settings.py +++ b/modules/settings.py @@ -52,6 +52,8 @@ jackTracker = [] # Jack game tracker mindTracker = [] # Mastermind (mmind) game tracker battleshipTracker = [] # Battleship game tracker footballTracker = [] # Football game tracker +lunarlanderTracker = [] # Lunar Lander game tracker +potatogunnerTracker = [] # Potato Gunner game tracker # Memory Management Constants MAX_MSG_HISTORY = 250 @@ -260,6 +262,7 @@ try: whoami_enabled = config['general'].getboolean('whoami', True) dad_jokes_enabled = config['general'].getboolean('DadJokes', False) dad_jokes_emojiJokes = config['general'].getboolean('DadJokesEmoji', False) + weekday_enabled = config['general'].getboolean('weekday', True) bee_enabled = config['general'].getboolean('bee', False) # ๐Ÿ off by default undocumented bible_enabled = config['general'].getboolean('verse', False) # verse command solar_conditions_enabled = config['general'].getboolean('spaceWeather', True) @@ -494,6 +497,7 @@ try: hangman_enabled = config['games'].getboolean('hangman', True) hamtest_enabled = config['games'].getboolean('hamtest', True) tictactoe_enabled = config['games'].getboolean('tictactoe', True) + lunarlander_enabled = config['games'].getboolean('lunarlander', True) quiz_enabled = config['games'].getboolean('quiz', False) survey_enabled = config['games'].getboolean('survey', False) default_survey = config['games'].get('defaultSurvey', 'example') # default example @@ -502,6 +506,7 @@ try: wordOfTheDay = config['games'].getboolean('wordOfTheDay', True) battleship_enabled = config['games'].getboolean('battleShip', True) football_enabled = config['games'].getboolean('football', True) + potatogunner_enabled = config['games'].getboolean('potatogunner', True) # messaging settings responseDelay = config['messagingSettings'].getfloat('responseDelay', 0.7) # default 0.7 diff --git a/modules/system.py b/modules/system.py index f27ec6c..eab5c99 100644 --- a/modules/system.py +++ b/modules/system.py @@ -75,6 +75,12 @@ if solar_conditions_enabled: else: hf_band_conditions = False +# Weekday Configuration +if weekday_enabled: + from modules.weekday import * # from the spudgunman/meshing-around repo + trap_list = trap_list + trap_list_weekday # items weekday, w: + help_message = help_message + ", weekday" + # Command History Configuration if enableCmdHistory: trap_list = trap_list + ("history",) @@ -217,6 +223,18 @@ if tictactoe_enabled: from modules.games.tictactoe import TicTacToe # from the spudgunman/meshing-around repo tictactoe = TicTacToe(display_module=None) trap_list = trap_list + ("tictactoe","tic-tac-toe",) + games_enabled = True + +if potatogunner_enabled: + from modules.games.potatogunner import potatogunner # from the spudgunman/meshing-around repo + trap_list = trap_list + ("spudgunner","spudgun",) + games_enabled = True + +if lunarlander_enabled: + from modules.games.lunarlander import LunarLander # Lunar Lander simulation + lunarlander = LunarLander() + trap_list = trap_list + ("lunarlander",) + games_enabled = True if quiz_enabled: from modules.games.quiz import * # from the spudgunman/meshing-around repo @@ -277,6 +295,10 @@ if games_enabled is True: gamesCmdList += "battleship, " if football_enabled: gamesCmdList += "football, " + if lunarlander_enabled: + gamesCmdList += "lunarLander, " + if potatogunner_enabled: + gamesCmdList += "spudgunner, " gamesCmdList = gamesCmdList[:-2] # remove the last comma else: gamesCmdList = "" @@ -526,7 +548,7 @@ def cleanup_game_trackers(current_time): tracker_names = [ 'dwPlayerTracker', 'lemonadeTracker', 'jackTracker', 'vpTracker', 'mindTracker', 'golfTracker', - 'hangmanTracker', 'hamtestTracker', 'tictactoeTracker', 'surveyTracker', 'battleshipTracker', 'footballTracker' + 'hangmanTracker', 'hamtestTracker', 'tictactoeTracker', 'surveyTracker', 'battleshipTracker', 'potatogunnerTracker', 'lunarlanderTracker', 'footballTracker' ] for tracker_name in tracker_names: diff --git a/modules/weekday.py b/modules/weekday.py new file mode 100644 index 0000000..785c00f --- /dev/null +++ b/modules/weekday.py @@ -0,0 +1,322 @@ +# Weekday Module for meshbot +# Provides detailed information about a given date including day of week, day of year, weeks remaining, etc. +# Based on ideas from https://github.com/coding-horror/basic-computer-games/blob/main/95_Weekday/python/weekday.py + +import datetime +import calendar +from modules.log import logger + +trap_list_weekday = ("weekday", ) +help_text_weekday = "weekday - Get info about today or a date: weekday or weekday MM,DD,YYYY" + + +def is_leap_year(year: int) -> bool: + """Check if a year is a leap year.""" + if (year % 4) != 0: + return False + return True if (year % 100) != 0 else year % 400 == 0 + +def get_day_of_week_name(date_obj: datetime.date) -> str: + """Get the day of week name for a given date.""" + days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + return days[date_obj.weekday()] + + +def get_week_number(date_obj: datetime.date) -> int: + """Get the ISO week number for a given date.""" + return date_obj.isocalendar()[1] + + +def is_friday_13th(date_obj: datetime.date) -> bool: + """Check if the date is Friday the 13th.""" + return date_obj.day == 13 and date_obj.weekday() == 4 # 4 = Friday + + +def get_day_of_year(date_obj: datetime.date) -> int: + """Get the day number within the year (1-366).""" + return date_obj.timetuple().tm_yday + + +def get_days_left_in_year(date_obj: datetime.date) -> int: + """Get the number of days remaining in the year after the given date.""" + end_of_year = datetime.date(date_obj.year, 12, 31) + delta = end_of_year - date_obj + return delta.days + + +def get_days_left_in_month(date_obj: datetime.date) -> int: + """Get the number of days remaining in the month after the given date.""" + # Get the last day of the month + last_day = calendar.monthrange(date_obj.year, date_obj.month)[1] + end_of_month = datetime.date(date_obj.year, date_obj.month, last_day) + delta = end_of_month - date_obj + return delta.days + + +def is_even_week(date_obj: datetime.date) -> bool: + """Check if the date falls on an even week number.""" + week_num = get_week_number(date_obj) + return week_num % 2 == 0 + + +def get_days_till_christmas(date_obj: datetime.date) -> tuple: + """Calculate days until Christmas. + + Returns: + A tuple of (days_until, christmas_date) + """ + # Get Christmas of the current year + christmas = datetime.date(date_obj.year, 12, 25) + + # If Christmas has already passed this year, use next year's Christmas + if date_obj > christmas: + christmas = datetime.date(date_obj.year + 1, 12, 25) + + delta = christmas - date_obj + return delta.days, christmas + + +def get_days_ago(date_obj: datetime.date) -> tuple: + """Calculate how many days and years ago a date was. + + Returns: + A tuple of (days_ago, years_ago) or (0, 0) if date is in the future + """ + today = datetime.date.today() + + if date_obj >= today: + return 0, 0 + + delta = today - date_obj + days_ago = delta.days + years_ago = days_ago // 365 + + return days_ago, years_ago + + +def get_season(date_obj: datetime.date) -> str: + """Get the season for a given date. + + Returns: + A string with the season (Spring, Summer, Fall, Winter) + """ + month = date_obj.month + day = date_obj.day + + # Spring: March 20 - June 20 + # Summer: June 21 - September 22 + # Fall: September 23 - December 21 + # Winter: December 22 - March 19 + + if (month == 3 and day >= 20) or (month in [4, 5]) or (month == 6 and day <= 20): + return "๐ŸŒธ Spring" + elif (month == 6 and day >= 21) or (month in [7, 8]) or (month == 9 and day <= 22): + return "โ˜€๏ธ Summer" + elif (month == 9 and day >= 23) or (month in [10, 11]) or (month == 12 and day <= 21): + return "๐Ÿ‚ Fall" + else: + return "โ„๏ธ Winter" + + +def get_business_days_left(date_obj: datetime.date) -> int: + """Get the number of business days (Mon-Fri) remaining in the year. + + Returns: + An integer of business days left + """ + end_of_year = datetime.date(date_obj.year, 12, 31) + business_days = 0 + + current = date_obj + datetime.timedelta(days=1) # Start from tomorrow + while current <= end_of_year: + if current.weekday() < 5: # Monday=0, Friday=4 + business_days += 1 + current += datetime.timedelta(days=1) + + return business_days + + +def get_week_of_month(date_obj: datetime.date) -> int: + """Get which week of the month the date falls on (1-5). + + Week 1: Days 1-7 + Week 2: Days 8-14 + etc. + + Returns: + An integer representing the week of month (1-5) + """ + return (date_obj.day - 1) // 7 + 1 + +def to_roman_numeral(num: int) -> str: + """Convert an integer to Roman numerals. + + Returns: + A string with the Roman numeral representation + """ + val = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1] + syms = ["M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"] + roman_num = '' + + for i in range(len(val)): + count = int(num / val[i]) + roman_num += syms[i] * count + num -= val[i] * count + + return roman_num + + +def get_days_till_next_holiday(date_obj: datetime.date) -> str: + """Get the countdown to the next major holiday. + + Returns: + A string with holiday name and days until it + """ + # holidays is a list of tuples: (holiday_name, month, day) + holidays = [ + ("New Year's Day", 1, 1), + ("St. Patrick's Day", 3, 17), + ("Independence Day", 7, 4), + ("Christmas", 12, 25), + ] + + today = datetime.date.today() + next_holiday = None + min_days = float('inf') + + for holiday_name, month, day in holidays: + if month is None: + # Skip variable holidays like Easter for simplicity + continue + + # Check this year + holiday_date = datetime.date(date_obj.year, month, day) + if holiday_date >= date_obj: + days_until = (holiday_date - date_obj).days + if days_until < min_days: + min_days = days_until + next_holiday = (holiday_name, days_until) + + # Check next year if we don't find one this year + if next_holiday is None: + holiday_date = datetime.date(date_obj.year + 1, month, day) + days_until = (holiday_date - date_obj).days + if days_until < min_days: + min_days = days_until + next_holiday = (holiday_name, days_until) + + if next_holiday: + return f"{next_holiday[0]}: {next_holiday[1]} days" + return "No holiday found" + + +def parse_date_input(date_str: str) -> datetime.date | None: + """Parse a date string in MM,DD,YYYY format. + + Returns: + A datetime.date object or None if parsing fails. + """ + try: + parts = date_str.strip().split(',') + if len(parts) != 3: + return None + month, day, year = int(parts[0]), int(parts[1]), int(parts[2]) + + # Validate date + if year < 1901: + return None + + date_obj = datetime.date(year, month, day) + return date_obj + except (ValueError, TypeError): + return None + + +def get_weekday_info(date_obj: datetime.date = None) -> str: + """Get comprehensive weekday information for a given date. + + Args: + date_obj: A datetime.date object. If None, uses today's date. + + Returns: + A formatted string with weekday information. + """ + if date_obj is None: + date_obj = datetime.date.today() + + today = datetime.date.today() + day_name = get_day_of_week_name(date_obj) + day_of_year = get_day_of_year(date_obj) + days_left = get_days_left_in_year(date_obj) + days_left_month = get_days_left_in_month(date_obj) + week_number = get_week_number(date_obj) + is_even = is_even_week(date_obj) + week_type = "even" if is_even else "odd" + season = get_season(date_obj) + leap = "Leap Year ๐ŸŽฏ" if is_leap_year(date_obj.year) else "" + business_days = get_business_days_left(date_obj) + week_of_month = get_week_of_month(date_obj) + + # Build the response message + msg = f"๐Ÿ“… {date_obj.strftime('%m/%d/%Y')} is a {day_name} of {calendar.month_name[date_obj.month]}" + + if is_friday_13th(date_obj): + msg += " (FRIDAY THE 13TH - BEWARE! ๐Ÿ˜ฑ)" + msg += "\n" + + msg += f"๐Ÿงฎ Day {day_of_year}, of {date_obj.year}\n" + msg += f"๐Ÿ“Š Week #{week_number} ({week_type} week). Week {week_of_month} of month, in {season}\n" + msg += f"โณ {days_left} days remain in {date_obj.year} and {business_days} week days\n" + msg += f"๐Ÿ—“๏ธ {days_left_month} days left this month\n" + + if leap: + msg += f"๐ŸŽฏ {leap}\n" + + msg += f"๐ŸŽ‰ {get_days_till_next_holiday(date_obj)}\n" + + # Add past date info + if date_obj < today: + days_ago, years_ago = get_days_ago(date_obj) + msg += f"โฐ That was {years_ago} years and {days_ago % 365} days ago" + elif date_obj == today: + pass + else: + days_future = (date_obj - today).days + msg += f"โฐ That's {days_future} days from now" + + return msg + + +def weekdayHandler(message: str, message_from_id: int, deviceID: str) -> str: + """Handle weekday command with optional date parameter. + + Usage: + weekday - Show info for today + weekday MM,DD,YYYY - Show info for a specific date + + Args: + message: The full message including the command + message_from_id: The ID of the user + deviceID: The device ID + + Returns: + A formatted string with weekday information or an error message. + """ + try: + # Extract the date parameter if provided + parts = message.split() + date_obj = None + + if len(parts) > 1: + # User provided a date + date_str = " ".join(parts[1:]) + date_obj = parse_date_input(date_str) + + if date_obj is None: + return "โŒ Invalid date format. Use: weekday MM,DD,YYYY\nExample: weekday 03,24,1979" + + return get_weekday_info(date_obj) + + except Exception as e: + logger.error(f"Error in weekdayHandler: {e}") + return f"โŒ Error processing weekday command: {str(e)}"