mirror of
https://github.com/SpudGunMan/meshing-around.git
synced 2026-10-30 22:01:57 +01:00
Merge branch 'main' into football
This commit is contained in:
8 files changed
+2234
-2
No files matched your search
@@ -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:**
|
||||
- `<angle>,<power>` — Fire spud at that angle with that PSI power (e.g., `45,60`)
|
||||
- `<angle>` — 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!
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
+23
-1
@@ -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:
|
||||
|
||||
@@ -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)}"
|
||||
Reference in new issue
Block a user