Merge branch 'main' into football

This commit is contained in:
Kelly authored and GitHub committed 2026-08-25 18:57:10 -07:00
commit 2b448a0748
8 files changed
+2234 -2

No files matched your search

+230
View File
@@ -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!
+813
View File
@@ -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()
+755
View File
@@ -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()
+5
View File
@@ -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
View File
@@ -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:
+322
View File
@@ -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)}"