From 2b21257d26540fd60c60b1c082aab959a1b0d799 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Fri, 13 Feb 2026 23:05:19 -0500 Subject: [PATCH] Add POV template engine for combat messages --- src/mudlib/render/pov.py | 106 +++++++++++++++++++++ tests/test_pov.py | 193 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 299 insertions(+) create mode 100644 src/mudlib/render/pov.py create mode 100644 tests/test_pov.py diff --git a/src/mudlib/render/pov.py b/src/mudlib/render/pov.py new file mode 100644 index 0000000..00bc687 --- /dev/null +++ b/src/mudlib/render/pov.py @@ -0,0 +1,106 @@ +"""POV template engine for combat messages.""" + +import re +from typing import Any + + +def render_pov( + template: str, + viewer: Any | None, + attacker: Any | None, + defender: Any | None, +) -> str: + """Render a combat message template from a specific viewer's POV. + + Args: + template: Message template with {attacker}, {defender}, and + contextual tags like {s}, {es}, {y|ies}, {his}, {him} + viewer: Entity viewing the message (determines "You" vs name) + attacker: Attacking entity + defender: Defending entity + + Returns: + Rendered message with appropriate perspective substitutions + + Contextual tags apply to the most recently mentioned entity: + - {s} → "" for 2nd person, "s"/"es" for 3rd (smart conjugation) + - {es} → "" for 2nd person, "es" for 3rd person + - {y|ies} → left form for 2nd person, right form for 3rd person + - {his} → "your" for 2nd person, "his" for 3rd person + - {him} → "you" for 2nd person, "him" for 3rd person + """ + if not template: + return template + + # Track whether the last entity mentioned was "You" (2nd person) + last_was_you = False + + # Pattern to match all tags + tag_pattern = re.compile(r"\{(attacker|defender|s|es|his|him|[^}]*\|[^}]*)\}") + + # Process template character by character, building result + result = [] + pos = 0 + + for match in tag_pattern.finditer(template): + # Add text before this tag + result.append(template[pos : match.start()]) + + tag = match.group(0) + tag_name = match.group(1) + + # Entity tags + if tag_name in ("attacker", "defender"): + entity = attacker if tag_name == "attacker" else defender + if entity is viewer: + last_was_you = True + result.append("You") + else: + last_was_you = False + result.append(entity.name if entity else "") + + # Contextual verb conjugation tags + elif tag == "{s}": + if last_was_you: + result.append("") + else: + # Check previous character for smart conjugation + prev_text = "".join(result) + if ( + prev_text + and prev_text[-1:] in "sxz" + or (len(prev_text) >= 2 and prev_text[-2:] in ("ch", "sh")) + ): + result.append("es") + else: + result.append("s") + + elif tag == "{es}": + result.append("" if last_was_you else "es") + + elif tag == "{his}": + result.append("your" if last_was_you else "his") + + elif tag == "{him}": + # Check if "self" follows to form reflexive pronoun + remaining = template[match.end() :] + if remaining.startswith("self"): + result.append("your" if last_was_you else "him") + else: + result.append("you" if last_was_you else "him") + + elif "|" in tag: + # Handle {a|b} pattern + content = tag[1:-1] # Strip { and } + left, right = content.split("|", 1) + result.append(left if last_was_you else right) + + else: + result.append(tag) + + pos = match.end() + + # Add remaining text after last tag + result.append(template[pos:]) + + return "".join(result) diff --git a/tests/test_pov.py b/tests/test_pov.py new file mode 100644 index 0000000..e0200ee --- /dev/null +++ b/tests/test_pov.py @@ -0,0 +1,193 @@ +"""Tests for POV template engine.""" + +from dataclasses import dataclass + +import pytest + +from mudlib.render.pov import render_pov + + +@dataclass +class MockEntity: + """Simple entity for testing.""" + + name: str + + +@pytest.fixture +def jared(): + """Attacker entity.""" + return MockEntity(name="Jared") + + +@pytest.fixture +def goku(): + """Defender entity.""" + return MockEntity(name="Goku") + + +@pytest.fixture +def vegeta(): + """Bystander entity.""" + return MockEntity(name="Vegeta") + + +def test_attacker_pov_basic(jared, goku): + """Attacker sees 'You' for self, defender name for target.""" + template = "{attacker} hit{s} {defender}" + result = render_pov(template, viewer=jared, attacker=jared, defender=goku) + assert result == "You hit Goku" + + +def test_defender_pov_basic(jared, goku): + """Defender sees attacker name, 'You' for self.""" + template = "{attacker} hit{s} {defender}" + result = render_pov(template, viewer=goku, attacker=jared, defender=goku) + assert result == "Jared hits You" + + +def test_bystander_pov_basic(jared, goku, vegeta): + """Bystander sees both entity names.""" + template = "{attacker} hits {defender}" + result = render_pov(template, viewer=vegeta, attacker=jared, defender=goku) + assert result == "Jared hits Goku" + + +def test_verb_s_conjugation_you(jared, goku): + """Verb with {s} after You gets no suffix.""" + template = "{attacker} punch{s} {defender}" + result = render_pov(template, viewer=jared, attacker=jared, defender=goku) + assert result == "You punch Goku" + + +def test_verb_s_conjugation_third_person(jared, goku, vegeta): + """Verb with {s} after name gets 's' suffix.""" + template = "{attacker} punch{s} {defender}" + result = render_pov(template, viewer=vegeta, attacker=jared, defender=goku) + assert result == "Jared punches Goku" + + +def test_verb_es_conjugation_you(jared, goku): + """Verb with {es} after You gets no suffix.""" + template = "{attacker} lurch{es} forward" + result = render_pov(template, viewer=jared, attacker=jared, defender=goku) + assert result == "You lurch forward" + + +def test_verb_es_conjugation_third_person(jared, goku, vegeta): + """Verb with {es} after name gets 'es' suffix.""" + template = "{attacker} lurch{es} forward" + result = render_pov(template, viewer=vegeta, attacker=jared, defender=goku) + assert result == "Jared lurches forward" + + +def test_irregular_verb_y_ies_you(jared, goku): + """Irregular verb {y|ies} after You uses left form.""" + template = "{attacker} parr{y|ies} the blow" + result = render_pov(template, viewer=jared, attacker=jared, defender=goku) + assert result == "You parry the blow" + + +def test_irregular_verb_y_ies_third_person(jared, goku, vegeta): + """Irregular verb {y|ies} after name uses right form.""" + template = "{attacker} parr{y|ies} the blow" + result = render_pov(template, viewer=vegeta, attacker=jared, defender=goku) + assert result == "Jared parries the blow" + + +def test_pronoun_his_you(jared, goku): + """{his} after You becomes 'your'.""" + template = "{attacker} raise{s} {his} fist" + result = render_pov(template, viewer=jared, attacker=jared, defender=goku) + assert result == "You raise your fist" + + +def test_pronoun_his_third_person(jared, goku, vegeta): + """{his} after name becomes 'his'.""" + template = "{attacker} raise{s} {his} fist" + result = render_pov(template, viewer=vegeta, attacker=jared, defender=goku) + assert result == "Jared raises his fist" + + +def test_pronoun_him_you(jared, goku): + """{him} after You becomes 'you'.""" + template = "{attacker} brace{s} {him}self" + result = render_pov(template, viewer=jared, attacker=jared, defender=goku) + assert result == "You brace yourself" + + +def test_pronoun_him_third_person(jared, goku, vegeta): + """{him} after name becomes 'him'.""" + template = "{attacker} brace{s} {him}self" + result = render_pov(template, viewer=vegeta, attacker=jared, defender=goku) + assert result == "Jared braces himself" + + +def test_mixed_template_attacker_pov(jared, goku): + """Complex template with multiple substitutions, attacker POV.""" + template = "{attacker} slam{s} {his} fist into {defender}" + result = render_pov(template, viewer=jared, attacker=jared, defender=goku) + assert result == "You slam your fist into Goku" + + +def test_mixed_template_defender_pov(jared, goku): + """Complex template with multiple substitutions, defender POV.""" + template = "{attacker} slam{s} {his} fist into {defender}" + result = render_pov(template, viewer=goku, attacker=jared, defender=goku) + assert result == "Jared slams his fist into You" + + +def test_mixed_template_bystander_pov(jared, goku, vegeta): + """Complex template with multiple substitutions, bystander POV.""" + template = "{attacker} slam{s} {his} fist into {defender}" + result = render_pov(template, viewer=vegeta, attacker=jared, defender=goku) + assert result == "Jared slams his fist into Goku" + + +def test_defender_contextual_tags(jared, goku): + """Contextual tags apply to defender when they precede them.""" + template = "{defender} brace{s} {him}self against {attacker}" + result = render_pov(template, viewer=goku, attacker=jared, defender=goku) + assert result == "You brace yourself against Jared" + + +def test_defender_contextual_third_person(jared, goku, vegeta): + """Contextual tags apply to defender in third person.""" + template = "{defender} brace{s} {him}self against {attacker}" + result = render_pov(template, viewer=vegeta, attacker=jared, defender=goku) + assert result == "Goku braces himself against Jared" + + +def test_plain_text_passthrough(): + """Template with no POV tags passes through unchanged.""" + template = "The battle rages on!" + result = render_pov(template, viewer=None, attacker=None, defender=None) + assert result == "The battle rages on!" + + +def test_you_capitalization_mid_sentence(jared, goku): + """'You' is capitalized even when not at start of sentence.""" + template = "The blow strikes {defender} hard" + result = render_pov(template, viewer=goku, attacker=jared, defender=goku) + assert result == "The blow strikes You hard" + + +def test_multiple_entity_references(jared, goku): + """Context switches between entities correctly.""" + template = "{attacker} throw{s} a punch at {defender}, but {defender} dodge{s} it" + result = render_pov(template, viewer=goku, attacker=jared, defender=goku) + assert result == "Jared throws a punch at You, but You dodge it" + + +def test_multiple_entity_references_attacker_pov(jared, goku): + """Context switches correctly from attacker POV.""" + template = "{attacker} throw{s} a punch at {defender}, but {defender} dodg{es} it" + result = render_pov(template, viewer=jared, attacker=jared, defender=goku) + assert result == "You throw a punch at Goku, but Goku dodges it" + + +def test_you_capitalized_after_comma(jared, goku): + """'You' stays capitalized after comma/conjunction.""" + template = "{attacker} strikes, and {defender} fall{s}" + result = render_pov(template, viewer=goku, attacker=jared, defender=goku) + assert result == "Jared strikes, and You fall"