- Fix memory test infinite loop (#1, #3) — add max iterations counter - Fix os.system command injection (#2, #4) — use subprocess with list args - Fix demon difficulty scaling (#5) — scale health/damage per cycle - Fix relative import (#6) — absolute imports with sys.path fallback - Fix normalize_text non-ASCII (#7) — use isalpha/isdigit instead of isalnum - Fix calculate_similarity word order (#8) — set-based intersection - Add CMD to Dockerfile (#9) - Remove test files from prod image (#10) — .dockerignore + strip tests COPY - Fix test class import (#11) — lazy import in test methods - Fix pyproject.toml duplicate pytest (#12) — move to dev optional deps - Add resource limits to compose (#13) - Fix verses.json quality (#14) — fix garbled verse text
456 lines
14 KiB
Python
456 lines
14 KiB
Python
"""Arcane Purge - A text-based exorcist game using Bible verses as spell mechanics."""
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
if __name__ == "__main__":
|
|
_here = os.path.dirname(os.path.abspath(__file__))
|
|
if _here not in sys.path:
|
|
sys.path.insert(0, _here)
|
|
|
|
from data import load_verses, normalize_text
|
|
|
|
# Demon encounter definitions with escalating difficulty
|
|
DEMONS = [
|
|
{"name": "Shade of Whispers", "health": 30, "weaknesses": ["Binding"], "damage": 5},
|
|
{"name": "Gloom Wraith", "health": 45, "weaknesses": ["Healing"], "damage": 8},
|
|
{"name": "Shadow Stalker", "health": 60, "weaknesses": ["Exorcism", "Binding"], "damage": 10},
|
|
{
|
|
"name": "Abyssal Phantom",
|
|
"health": 80,
|
|
"weaknesses": ["Protection", "Exorcism"],
|
|
"damage": 15,
|
|
},
|
|
{"name": "Dread Incubus", "health": 100, "weaknesses": ["Binding", "Healing"], "damage": 18},
|
|
{
|
|
"name": "Nightmare Sovereign",
|
|
"health": 130,
|
|
"weaknesses": ["Judgment", "Exorcism"],
|
|
"damage": 22,
|
|
},
|
|
{
|
|
"name": "Void Archfiend",
|
|
"health": 160,
|
|
"weaknesses": ["Judgment", "Protection"],
|
|
"damage": 28,
|
|
},
|
|
{
|
|
"name": "Abyssal Lord",
|
|
"health": 200,
|
|
"weaknesses": ["Binding", "Judgment", "Exorcism"],
|
|
"damage": 35,
|
|
},
|
|
]
|
|
|
|
|
|
def clear_screen() -> None:
|
|
"""Clear the terminal screen."""
|
|
cmd = "cls" if os.name == "nt" else "clear"
|
|
subprocess.run(cmd.split(), check=False)
|
|
|
|
|
|
def show_status(player: dict, demon: dict, verses: list, unlocked: list) -> None:
|
|
"""Display the current game status including player stats, demon info, and available verses.
|
|
|
|
Args:
|
|
player: Player state dictionary with health, max_health, score, mana.
|
|
demon: Current demon dictionary with name, health, max_health, weaknesses.
|
|
verses: Full list of all verses.
|
|
unlocked: List of verse IDs that are unlocked.
|
|
"""
|
|
clear_screen()
|
|
print("=" * 50)
|
|
print(" ARCANED PURGE")
|
|
print("=" * 50)
|
|
print()
|
|
print(f"Player Health: {player['health']}/{player['max_health']}")
|
|
print(f"Score: {player['score']}")
|
|
print(f"Demons Vanquished: {player['demons_defeated']}")
|
|
print()
|
|
print(f"Encounter: {demon['name']}")
|
|
print(f"Demon Health: {demon['health']}/{demon['max_health']}")
|
|
print(f"Weaknesses: {', '.join(demon['weaknesses'])}")
|
|
print()
|
|
print("Available Verses:")
|
|
for i, verse in enumerate(verses):
|
|
if verse["id"] in unlocked:
|
|
marker = ">>" if verse["category"] in demon["weaknesses"] else " "
|
|
print(
|
|
f" {marker} {i + 1}) {verse['id']} ({verse['category']}, "
|
|
f"Difficulty: {verse['difficulty']})"
|
|
)
|
|
print()
|
|
print("-" * 50)
|
|
|
|
|
|
def calculate_similarity(text1: str, text2: str) -> float:
|
|
"""Calculate similarity between two strings using character matching ratio.
|
|
|
|
Args:
|
|
text1: First normalized text string.
|
|
text2: Second normalized text string.
|
|
|
|
Returns:
|
|
Float between 0.0 and 1.0 representing similarity.
|
|
"""
|
|
if not text1 and not text2:
|
|
return 1.0
|
|
if not text1 or not text2:
|
|
return 0.0
|
|
|
|
words1 = set(text1.split())
|
|
words2 = set(text2.split())
|
|
matches = len(words1 & words2)
|
|
max_len = max(len(words1), len(words2))
|
|
return matches / max_len if max_len > 0 else 0.0
|
|
|
|
|
|
def get_hint(verse_text: str) -> str:
|
|
"""Generate a hint showing the first letter of each word.
|
|
|
|
Args:
|
|
verse_text: The full verse text.
|
|
|
|
Returns:
|
|
String with first letter of each word separated by spaces.
|
|
"""
|
|
words = verse_text.split()
|
|
return " ".join(w[0] if w else "?" for w in words)
|
|
|
|
|
|
def cast_verse(player: dict, demon: dict, verse: dict) -> tuple[bool, int, float]:
|
|
"""Execute the verse casting phase of combat.
|
|
|
|
Player types the verse text. If correct, damage is dealt to demon.
|
|
If wrong, player takes damage. Supports hints and partial credit.
|
|
|
|
Args:
|
|
player: Player state dictionary.
|
|
demon: Current demon dictionary.
|
|
verse: Selected verse dictionary.
|
|
|
|
Returns:
|
|
Tuple of (success: bool, damage_dealt: int, elapsed_time: float).
|
|
"""
|
|
print(f"\n--- Casting {verse['id']} ({verse['category']}) ---")
|
|
print(f"Reference: {verse['reference']}")
|
|
print(f'\nVerse to type:\n"{verse["text"]}"')
|
|
print("\nType the verse above (or press H for hint):")
|
|
|
|
start_time = time.time()
|
|
typed = input("> ")
|
|
elapsed = time.time() - start_time
|
|
|
|
# Handle hint requests during input
|
|
while typed.upper() == "H":
|
|
print(f"\nHint: {get_hint(verse['text'])}")
|
|
print("Type the verse (or press H for hint again):")
|
|
typed = input("> ")
|
|
|
|
typed_normalized = normalize_text(typed)
|
|
verse_normalized = normalize_text(verse["text"])
|
|
|
|
if typed_normalized == verse_normalized:
|
|
damage = verse["difficulty"] * 5
|
|
if verse["category"] in demon["weaknesses"]:
|
|
damage = damage * 2
|
|
return True, damage, elapsed
|
|
|
|
similarity = calculate_similarity(typed_normalized, verse_normalized)
|
|
if similarity >= 0.8:
|
|
print(f"\nPartial match ({similarity:.0%} correct). Retry allowed.")
|
|
print(f'Verse: "{verse["text"]}"')
|
|
print("Try again:")
|
|
typed = input("> ")
|
|
typed_normalized = normalize_text(typed)
|
|
elapsed = time.time() - start_time
|
|
|
|
if typed_normalized == verse_normalized:
|
|
damage = verse["difficulty"] * 3
|
|
if verse["category"] in demon["weaknesses"]:
|
|
damage = damage * 2
|
|
return True, damage, elapsed
|
|
|
|
# Failed cast
|
|
player["health"] -= demon["damage"]
|
|
player["score"] -= verse["difficulty"] * 2
|
|
print(f"\nSpell failed! ({similarity:.0%} match)")
|
|
print(f"Demon attacks! You take {demon['damage']} damage.")
|
|
if player["health"] < 0:
|
|
player["health"] = 0
|
|
return False, 0, elapsed
|
|
|
|
|
|
def memory_test(player: dict, demon: dict, verse: dict) -> tuple[bool, int]:
|
|
"""Execute the memory test phase after successful casting.
|
|
|
|
Player must recall and type the verse from memory. Success defeats demon.
|
|
Failure deals damage to player with retry chances.
|
|
|
|
Args:
|
|
player: Player state dictionary.
|
|
demon: Current demon dictionary.
|
|
verse: Verse that was successfully cast.
|
|
|
|
Returns:
|
|
Tuple of (success: bool, bonus_damage: int).
|
|
"""
|
|
print("\n--- Memory Test ---")
|
|
print("Type the verse from memory (do not look at it!):")
|
|
print("(Press H for hint if needed)")
|
|
|
|
attempts = 3
|
|
max_iterations = 10
|
|
iteration = 0
|
|
while attempts > 0 and iteration < max_iterations:
|
|
iteration += 1
|
|
typed = input("> ")
|
|
if typed.upper() == "H":
|
|
print(f"\nHint: {get_hint(verse['text'])}")
|
|
print("Type the verse from memory:")
|
|
continue
|
|
|
|
typed_normalized = normalize_text(typed)
|
|
verse_normalized = normalize_text(verse["text"])
|
|
|
|
if typed_normalized == verse_normalized:
|
|
bonus = verse["difficulty"] * 10
|
|
if verse["category"] in demon["weaknesses"]:
|
|
bonus = bonus * 2
|
|
print("\nMemory test passed! Powerful strike!")
|
|
return True, bonus
|
|
|
|
similarity = calculate_similarity(typed_normalized, verse_normalized)
|
|
if similarity >= 0.8:
|
|
attempts -= 1
|
|
print(f"\nClose ({similarity:.0%} correct). One more retry.")
|
|
print("Try again:")
|
|
continue
|
|
|
|
attempts -= 1
|
|
player["health"] -= demon["damage"]
|
|
player["score"] -= 5
|
|
if player["health"] < 0:
|
|
player["health"] = 0
|
|
if attempts > 0:
|
|
print(f"\nMemory test failed! Demon attacks! ({attempts} attempts left)")
|
|
print("Try again (press H for hint):")
|
|
|
|
print("\nMemory test failed! The demon resists.")
|
|
return False, 0
|
|
|
|
|
|
def unlock_new_verses(player: dict, all_verses: list, unlocked: list) -> list:
|
|
"""Unlock new verses based on demons defeated count.
|
|
|
|
Progression:
|
|
- Start: difficulty 1 verses (4 verses)
|
|
- After 1 demon: unlock difficulty 2
|
|
- After 2 demons: unlock difficulty 3
|
|
- After 3 demons: unlock difficulty 4
|
|
- After 4 demons: unlock difficulty 5
|
|
|
|
Args:
|
|
player: Player state dictionary.
|
|
all_verses: Full list of all verses.
|
|
unlocked: Current list of unlocked verse IDs.
|
|
|
|
Returns:
|
|
Updated list of unlocked verse IDs.
|
|
"""
|
|
defeated = player["demons_defeated"]
|
|
new_unlocks = []
|
|
|
|
if defeated >= 1:
|
|
max_diff = 2
|
|
elif defeated >= 2:
|
|
max_diff = 3
|
|
elif defeated >= 3:
|
|
max_diff = 4
|
|
elif defeated >= 4:
|
|
max_diff = 5
|
|
else:
|
|
max_diff = 1
|
|
|
|
for verse in all_verses:
|
|
if verse["difficulty"] <= max_diff and verse["id"] not in unlocked:
|
|
unlocked.append(verse["id"])
|
|
new_unlocks.append(verse["id"])
|
|
|
|
if new_unlocks:
|
|
print(f"\n*** New verses unlocked: {', '.join(new_unlocks)} ***")
|
|
|
|
return unlocked
|
|
|
|
|
|
def run_encounter(player: dict, all_verses: list, unlocked: list, demon_index: int) -> list:
|
|
"""Run a full demon encounter including casting and memory test phases.
|
|
|
|
Args:
|
|
player: Player state dictionary.
|
|
all_verses: Full list of all verses.
|
|
unlocked: List of unlocked verse IDs.
|
|
demon_index: Index into DEMONS list for this encounter.
|
|
|
|
Returns:
|
|
Updated list of unlocked verse IDs.
|
|
"""
|
|
demon_data = DEMONS[demon_index % len(DEMONS)]
|
|
cycle = demon_index // len(DEMONS)
|
|
scale = 1 + cycle * 0.25
|
|
demon = {
|
|
"name": demon_data["name"],
|
|
"health": int(demon_data["health"] * scale),
|
|
"max_health": int(demon_data["health"] * scale),
|
|
"weaknesses": demon_data["weaknesses"],
|
|
"damage": int(demon_data["damage"] * scale),
|
|
}
|
|
|
|
available = [v for v in all_verses if v["id"] in unlocked]
|
|
|
|
print(f"\nA {demon['name']} emerges from the darkness!")
|
|
time.sleep(1)
|
|
|
|
while demon["health"] > 0 and player["health"] > 0:
|
|
show_status(player, demon, all_verses, unlocked)
|
|
|
|
if not available:
|
|
print("No verses available! Game Over.")
|
|
player["health"] = 0
|
|
break
|
|
|
|
print("Select a verse to cast (enter number):")
|
|
choice = input("> ").strip()
|
|
|
|
try:
|
|
idx = int(choice) - 1
|
|
if idx < 0 or idx >= len(available):
|
|
print("Invalid selection. Try again.")
|
|
continue
|
|
selected = available[idx]
|
|
except ValueError:
|
|
print("Please enter a number.")
|
|
continue
|
|
|
|
success, damage, elapsed = cast_verse(player, demon, selected)
|
|
|
|
if player["health"] <= 0:
|
|
break
|
|
|
|
if success:
|
|
demon["health"] -= damage
|
|
time_bonus = 5 if elapsed < 10 else (3 if elapsed < 20 else (1 if elapsed < 30 else 0))
|
|
player["score"] += selected["difficulty"] * 10 + time_bonus
|
|
print(f"\nSpell successful! Dealt {damage} damage.")
|
|
print(f"Time: {elapsed:.1f}s (+{time_bonus} bonus)")
|
|
|
|
if demon["health"] <= 0:
|
|
demon["health"] = 0
|
|
print(f"\nThe {demon['name']} is weakened!")
|
|
|
|
if player.get("memory_mode"):
|
|
mem_success, bonus = memory_test(player, demon, selected)
|
|
if mem_success:
|
|
demon["health"] -= bonus
|
|
player["score"] += bonus
|
|
if demon["health"] < 0:
|
|
demon["health"] = 0
|
|
if player["health"] <= 0:
|
|
break
|
|
|
|
if demon["health"] <= 0:
|
|
break
|
|
|
|
input("\nPress Enter to continue...")
|
|
|
|
if demon["health"] <= 0:
|
|
player["demons_defeated"] += 1
|
|
player["score"] += 50
|
|
print(f"\n*** {demon['name']} has been vanquished! ***")
|
|
print(f"Score: {player['score']}")
|
|
|
|
if player["demons_defeated"] >= 5 and not player.get("memory_mode"):
|
|
player["memory_mode"] = True
|
|
print("*** Memory Mode unlocked! Memory tests now active. ***")
|
|
|
|
unlocked = unlock_new_verses(player, all_verses, unlocked)
|
|
input("\nPress Enter for next encounter...")
|
|
elif player["health"] <= 0:
|
|
print(f"\nThe {demon['name']} overwhelms you...")
|
|
|
|
return unlocked
|
|
|
|
|
|
def game_over(player: dict) -> None:
|
|
"""Display the game over screen with final statistics.
|
|
|
|
Args:
|
|
player: Final player state dictionary.
|
|
"""
|
|
clear_screen()
|
|
print("=" * 50)
|
|
print(" GAME OVER")
|
|
print("=" * 50)
|
|
print()
|
|
print(f"Final Score: {player['score']}")
|
|
print(f"Demons Vanquished: {player['demons_defeated']}")
|
|
verses_known = player.get("verses_unlocked", 0)
|
|
print(f"Verses Learned: {verses_known}")
|
|
memory_active = "Yes" if player.get("memory_mode") else "No"
|
|
print(f"Memory Mode: {memory_active}")
|
|
print()
|
|
print("The darkness recedes... for now.")
|
|
print("May your faith remain unbroken.")
|
|
print()
|
|
print("=" * 50)
|
|
|
|
|
|
def main() -> None:
|
|
"""Main entry point for the Arcane Purge game.
|
|
|
|
Initializes the game state, loads verses, and runs the main game loop
|
|
through demon encounters until the player is defeated.
|
|
"""
|
|
all_verses = load_verses()
|
|
unlocked = [v["id"] for v in all_verses if v["difficulty"] == 1]
|
|
|
|
player = {
|
|
"health": 100,
|
|
"max_health": 100,
|
|
"score": 0,
|
|
"demons_defeated": 0,
|
|
"verses_unlocked": len(unlocked),
|
|
"memory_mode": False,
|
|
}
|
|
|
|
clear_screen()
|
|
print("=" * 50)
|
|
print(" ARCANED PURGE")
|
|
print("=" * 50)
|
|
print()
|
|
print("You are an exorcist armed with the power of Scripture.")
|
|
print("Type Bible verses to cast spells against demonic forces.")
|
|
print("Match the verse exactly to deal damage.")
|
|
print("Pass memory tests to deliver the final blow.")
|
|
print()
|
|
print(f"You start with {len(unlocked)} verses unlocked.")
|
|
print("Defeat demons to unlock more powerful verses.")
|
|
print()
|
|
input("Press Enter to begin...")
|
|
|
|
demon_index = 0
|
|
while player["health"] > 0:
|
|
clear_screen()
|
|
unlocked = run_encounter(player, all_verses, unlocked, demon_index)
|
|
player["verses_unlocked"] = len(unlocked)
|
|
demon_index += 1
|
|
|
|
game_over(player)
|
|
input("\nPress Enter to exit...")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|