From 3fd58226cec446890069b174f2964887baa7d9ca Mon Sep 17 00:00:00 2001 From: secretary owner Date: Tue, 29 Sep 2026 08:40:17 +0000 Subject: [PATCH 1/2] Name combat action-bar targets by what the player perceives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TargetDropdown (attack/talk), DirectionalDropdown (move/dash toward/away) and the smite choice panel labelled targets with the internal entity id ("Атаковать goblin_chieftain_5"). They now use perceivedLabels, the same rule as the Nearby panel: perceived description (a stranger's race), else known name, with an ordinal suffix for duplicates. --- .../game/__tests__/ActionBar.test.tsx | 4 +- .../game/__tests__/ActionButton.test.tsx | 70 +++++++++++++++++++ .../game/action-bar/DirectionalDropdown.tsx | 39 ++++++----- .../game/action-bar/TargetDropdown.tsx | 15 ++-- frontend/src/components/game/targetLabels.ts | 7 +- 5 files changed, 108 insertions(+), 27 deletions(-) diff --git a/frontend/src/components/game/__tests__/ActionBar.test.tsx b/frontend/src/components/game/__tests__/ActionBar.test.tsx index a7bc9673..994b1786 100644 --- a/frontend/src/components/game/__tests__/ActionBar.test.tsx +++ b/frontend/src/components/game/__tests__/ActionBar.test.tsx @@ -246,8 +246,8 @@ describe("ActionBar — cost-type styling", () => { expect(duplicates).toEqual([]) // The smite panel items must include the target name. - expect(screen.getByRole("menuitem", { name: /Attack practice_thug$/ })).toBeTruthy() - expect(screen.getByRole("menuitem", { name: /Attack practice_thug \+ Smite \(slot 1\)/ })).toBeTruthy() + expect(screen.getByRole("menuitem", { name: /Attack Thug$/ })).toBeTruthy() + expect(screen.getByRole("menuitem", { name: /Attack Thug \+ Smite \(slot 1\)/ })).toBeTruthy() }) it("attack with multiple enemies shows target dropdown", () => { diff --git a/frontend/src/components/game/__tests__/ActionButton.test.tsx b/frontend/src/components/game/__tests__/ActionButton.test.tsx index 3f56abf5..952ba546 100644 --- a/frontend/src/components/game/__tests__/ActionButton.test.tsx +++ b/frontend/src/components/game/__tests__/ActionButton.test.tsx @@ -173,6 +173,76 @@ describe("ActionButton — target selection", () => { }) }) +describe("ActionButton — target labels never show internal ids", () => { + const attackAction = makeAction("attack", "action", [{ name: "target_id", type: "string", required: true }], { target_mode: "single", target_scope: "hostile" }) + const talkAction = makeAction("talk", "free", [{ name: "target_id", type: "string", required: true }], { target_mode: "single", target_scope: "any" }) + + function menuTexts(): string[] { + return screen.getAllByRole("menuitem").map((el) => el.textContent ?? "") + } + + it("attack options use the perceived description and number duplicates", () => { + setCombatState([attackAction], fullBudget, [ + { id: "goblin_chieftain_5", description: "Goblin Boss", distance_ft: 5, is_hostile: true }, + { id: "goblin_1", description: "Goblin", distance_ft: 10, is_hostile: true }, + { id: "goblin_2", description: "Goblin", distance_ft: 15, is_hostile: true }, + ]) + + render() + fireEvent.click(screen.getByTitle("attack desc")) + const texts = menuTexts() + expect(texts[0]).toContain("Attack Goblin Boss") + expect(texts[1]).toContain("Attack Goblin #1") + expect(texts[2]).toContain("Attack Goblin #2") + for (const text of texts) { + expect(text).not.toMatch(/goblin_/) + } + }) + + it("talk options name a stranger by race, not by id", () => { + setCombatState([talkAction], fullBudget, [ + { id: "npc_mirabel_blacksmith", description: "Human", distance_ft: 5, is_hostile: false }, + { id: "npc_tobin_guard", description: "Dwarf", distance_ft: 10, is_hostile: false }, + ]) + + render() + fireEvent.click(screen.getByTitle("talk desc")) + const texts = menuTexts() + expect(texts.some((text) => text.includes("Talk to Human"))).toBe(true) + expect(texts.some((text) => text.includes("Talk to Dwarf"))).toBe(true) + expect(texts.join(" ")).not.toMatch(/npc_/) + }) + + it("directional move options use the perceived description", () => { + setCombatState( + [makeAction("dash", "action", [{ name: "toward", type: "string", required: false }])], + fullBudget, + [{ id: "goblin_chieftain_5", description: "Goblin Boss", distance_ft: 10, direction: "N" }], + ) + + const { container } = render() + fireEvent.click(screen.getByTitle("dash desc")) + const dropdown = container.querySelector(".absolute.bottom-full") + expect(dropdown?.textContent).toContain("Dash toward Goblin Boss") + expect(dropdown?.textContent).not.toContain("goblin_chieftain_5") + }) + + it("smite choice names the target by its perceived description", () => { + setCombatState( + [attackAction], + fullBudget, + [{ id: "goblin_chieftain_5", description: "Goblin Boss", distance_ft: 5, is_hostile: true }], + [{ id: "spell_slot_1", max_uses: 2, current_uses: 1 }], + ) + + const { container } = render() + fireEvent.click(screen.getByTitle("attack desc")) + const smitePanel = container.querySelector("[data-testid='smite-choice']") + expect(smitePanel?.textContent).toContain("Goblin Boss") + expect(smitePanel?.textContent).not.toContain("goblin_chieftain_5") + }) +}) + describe("ActionButton — target scope filtering", () => { it("HOSTILE scope filters to hostile targets only", () => { setCombatState( diff --git a/frontend/src/components/game/action-bar/DirectionalDropdown.tsx b/frontend/src/components/game/action-bar/DirectionalDropdown.tsx index d4ea5589..d74db47d 100644 --- a/frontend/src/components/game/action-bar/DirectionalDropdown.tsx +++ b/frontend/src/components/game/action-bar/DirectionalDropdown.tsx @@ -1,11 +1,12 @@ import { Button } from "@/components/ui/button" import { ChevronDown } from "lucide-react" import { getActionLabel } from "./utils" +import { perceivedLabels } from "../targetLabels" interface DirectionalDropdownProps { name: string description: string - enemies: { id: string }[] + enemies: { id: string; description?: string; name?: string }[] disabled: boolean openDropdown: string | null setOpenDropdown: (v: string | null) => void @@ -22,6 +23,7 @@ export function DirectionalDropdown({ name, description, enemies, disabled, open const dataAttrs: Record = {} if (costType) dataAttrs["data-cost-type"] = costType if (depleted) dataAttrs["data-depleted"] = "" + const labels = perceivedLabels(enemies, t) return (
@@ -39,22 +41,25 @@ export function DirectionalDropdown({ name, description, enemies, disabled, open {openDropdown === name && (
- {enemies.map((e) => ( -
- - -
- ))} + {enemies.map((e) => { + const target = labels.get(e.id) ?? t("game:unknown_creature") + return ( +
+ + +
+ ) + })}
)}
diff --git a/frontend/src/components/game/action-bar/TargetDropdown.tsx b/frontend/src/components/game/action-bar/TargetDropdown.tsx index 9e04a280..8c00ab70 100644 --- a/frontend/src/components/game/action-bar/TargetDropdown.tsx +++ b/frontend/src/components/game/action-bar/TargetDropdown.tsx @@ -7,11 +7,12 @@ import { getSpellSlots } from "../spellSlots" import { SmiteChoice } from "../SmiteChoice" import { buildAttackParams } from "../attackParams" import { LayOnHandsAmountPicker } from "./LayOnHandsAmountPicker" +import { perceivedLabels } from "../targetLabels" interface TargetDropdownProps { name: string description: string - nearby: { id: string; distance_ft?: number; is_hostile?: boolean }[] + nearby: { id: string; description?: string; name?: string; distance_ft?: number; is_hostile?: boolean }[] scope: string selfId?: string disabled: boolean @@ -48,15 +49,17 @@ function buildTargets( }) } + // Same display rule as the Nearby panel: the id is only the action handle, never a label. + const names = perceivedLabels(nearby, t) for (const e of nearby) { if (scope === "hostile" && !e.is_hostile) continue if (scope === "ally" && e.is_hostile) continue - let label = e.id + let label = names.get(e.id) ?? t("game:unknown_creature") if (actionName === "attack") { - label = t("game:attack_target", { target: e.id }) + label = t("game:attack_target", { target: label }) } else if (actionName === "talk") { - label = t("game:talk_to", { target: e.id }) + label = t("game:talk_to", { target: label }) } targets.push({ @@ -77,6 +80,8 @@ export function TargetDropdown({ name, description, nearby, scope, selfId, disab if (depleted) dataAttrs["data-depleted"] = "" const targets = buildTargets(name, nearby, scope, selfId, t) + const targetName = (id: string) => + id === selfId ? t("game:target_self") : perceivedLabels(nearby, t).get(id) ?? t("game:unknown_creature") // Check if this is an attack action with available spell slots const isAttack = name === "attack" @@ -160,7 +165,7 @@ export function TargetDropdown({ name, description, nearby, scope, selfId, disab
{ setSmiteTargetId(null) diff --git a/frontend/src/components/game/targetLabels.ts b/frontend/src/components/game/targetLabels.ts index afb92674..030cd898 100644 --- a/frontend/src/components/game/targetLabels.ts +++ b/frontend/src/components/game/targetLabels.ts @@ -1,5 +1,3 @@ -import type { TFunction } from "i18next" - interface Perceived { id: string description?: string @@ -14,7 +12,10 @@ interface Perceived { * known name, else a neutral fallback. Entities that would share a label (two goblins) get an * ordinal suffix in list order so every control stays uniquely addressable. */ -export function perceivedLabels(entities: Perceived[], t: TFunction): Map { +export function perceivedLabels( + entities: Perceived[], + t: (key: string, opts?: Record) => string, +): Map { const base = entities.map((e) => e.description || e.name || t("game:unknown_creature")) const total = new Map() for (const label of base) total.set(label, (total.get(label) ?? 0) + 1) From b96137d0ab18ce8ec7976a822fb09c125d0ad480 Mon Sep 17 00:00:00 2001 From: secretary owner Date: Tue, 29 Sep 2026 08:40:17 +0000 Subject: [PATCH 2/2] Translate catalog item and attack names from the YAML catalogs Attack names ("longsword slash") reached the combat panel without gettext and neither they nor most item names (Shield, Chain Mail, ...) had a ru translation; perception.py kept a hand-maintained, incomplete _() list. - content_loader/catalog_messages.py lists every catalog item name, weapon attack_name and monster attack name; make messages appends them to messages.pot (regenerated here). - ru .po/.mo: translations for all catalog names; the hardcoded item list in perception.py is removed. - Combat panel weapon, inventory payload, ItemInfo and equipped names go through gettext (English output unchanged). - test_catalog_translations fails when a catalog name has no ru msgstr. --- Makefile | 1 + .../content_loader/catalog_messages.py | 71 ++ src/dnd_simulator/core/awareness.py | 5 +- .../layers/entities/awareness_builder.py | 4 +- .../layers/entities/perception.py | 8 +- src/dnd_simulator/locale/messages.pot | 723 +++++++++++------- .../locale/ru/LC_MESSAGES/dnd_simulator.mo | Bin 35391 -> 37722 bytes .../locale/ru/LC_MESSAGES/dnd_simulator.po | 125 +++ .../service/transport_payloads.py | 2 +- tests/unit/test_catalog_translations.py | 152 ++++ 10 files changed, 795 insertions(+), 296 deletions(-) create mode 100644 src/dnd_simulator/content_loader/catalog_messages.py create mode 100644 tests/unit/test_catalog_translations.py diff --git a/Makefile b/Makefile index 62813112..9c32ffa9 100644 --- a/Makefile +++ b/Makefile @@ -52,6 +52,7 @@ serve: stop messages: find src/dnd_simulator -name '*.py' | xargs pygettext3 --keyword=_ --output=src/dnd_simulator/locale/messages.pot + uv run python -m dnd_simulator.content_loader.catalog_messages src/dnd_simulator/locale/messages.pot content compile-messages: uv run pybabel compile -d src/dnd_simulator/locale -D dnd_simulator diff --git a/src/dnd_simulator/content_loader/catalog_messages.py b/src/dnd_simulator/content_loader/catalog_messages.py new file mode 100644 index 00000000..c16d8332 --- /dev/null +++ b/src/dnd_simulator/content_loader/catalog_messages.py @@ -0,0 +1,71 @@ +"""Translatable strings of the YAML catalogs — item names and attack names. + +These names are content, not code, so ``pygettext3`` never sees them, yet the player sees them +through gettext (combat log, combat panel, inventory). This module is the one list of them: +``make messages`` appends them to the .pot, and a unit test requires a Russian translation for +each, so a new catalog entry cannot ship untranslated. + +Monster *names* are not here: they are ``LocalizedText`` maps in the YAML itself. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from dnd_simulator.content_loader.catalogs import load_catalog +from dnd_simulator.content_loader.schemas import ItemContent, MonsterTemplateContent + + +def catalog_msgids(content_root: Path) -> dict[str, list[str]]: + """Every translatable catalog string mapped to the catalog files that use it. + + Covers item ``name`` and weapon ``attack_name`` (``catalogs/items``) and monster attack + ``name`` (``catalogs/monsters``). References are ``catalogs//.yaml`` paths. + """ + msgids: dict[str, list[str]] = {} + + def add(msgid: str, ref: str) -> None: + if msgid: + msgids.setdefault(msgid, []).append(ref) + + catalogs = content_root / "catalogs" + for item_id, item in load_catalog(catalogs / "items", ItemContent).items(): + ref = f"catalogs/items/{item_id}.yaml" + add(item.name, ref) + add(item.attack_name or "", ref) + for monster_id, monster in load_catalog(catalogs / "monsters", MonsterTemplateContent).items(): + for attack in monster.attacks: + add(attack.name, f"catalogs/monsters/{monster_id}.yaml") + return msgids + + +def _quote(text: str) -> str: + return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def pot_entries(msgids: dict[str, list[str]], existing: set[str]) -> str: + """.pot entries for the msgids not in *existing*, sorted, with ``#:`` source references.""" + entries = [] + for msgid in sorted(set(msgids) - existing): + refs = " ".join(f"content/{ref}" for ref in sorted(set(msgids[msgid]))) + entries.append(f'#: {refs}\nmsgid {_quote(msgid)}\nmsgstr ""\n') + return "\n".join(entries) + + +def append_to_pot(pot_path: Path, content_root: Path) -> int: + """Append the catalog msgids missing from *pot_path*; return how many were added.""" + from babel.messages.pofile import read_po + + text = pot_path.read_text(encoding="utf-8") + with pot_path.open("rb") as fh: + existing = {str(message.id) for message in read_po(fh) if message.id} + missing = pot_entries(catalog_msgids(content_root), existing) + if missing: + pot_path.write_text(text.rstrip("\n") + "\n\n" + missing, encoding="utf-8") + return missing.count("\nmsgid ") + + +if __name__ == "__main__": # pragma: no cover — invoked by ``make messages`` + added = append_to_pot(Path(sys.argv[1]), Path(sys.argv[2])) + print(f"catalog msgids appended: {added}") diff --git a/src/dnd_simulator/core/awareness.py b/src/dnd_simulator/core/awareness.py index f9682720..fed1e92f 100644 --- a/src/dnd_simulator/core/awareness.py +++ b/src/dnd_simulator/core/awareness.py @@ -12,6 +12,7 @@ from dnd_simulator.core.conditions import Condition from dnd_simulator.core.items import EquipmentSlot from dnd_simulator.core.models import EventType +from dnd_simulator.i18n import _ if TYPE_CHECKING: from dnd_simulator.core.action import ActionType @@ -147,10 +148,10 @@ def item_props(item: Item) -> dict[str, object] | None: def item_info(item: Item) -> ItemInfo: - """Build the standard ItemInfo view of an inventory item.""" + """Build the standard ItemInfo view of an inventory item; ``name`` is the localized display name.""" return ItemInfo( id=item.id, - name=item.name, + name=_(item.name), description=describe_item(item), item_type=str(item.item_type), price=item.price, diff --git a/src/dnd_simulator/layers/entities/awareness_builder.py b/src/dnd_simulator/layers/entities/awareness_builder.py index 7b5e22fd..76602d10 100644 --- a/src/dnd_simulator/layers/entities/awareness_builder.py +++ b/src/dnd_simulator/layers/entities/awareness_builder.py @@ -119,7 +119,7 @@ def build_equipped(creature: Creature) -> list[EquippedInfo]: ] return [ EquippedInfo( - slot=slot, item_id=item.id, name=item.name, description=describe_item(item), props=item_props(item) + slot=slot, item_id=item.id, name=_(item.name), description=describe_item(item), props=item_props(item) ) for slot, item in slots if item is not None @@ -269,7 +269,7 @@ def build_combat_awareness(self, creature: Creature, query_fn: QueryFn | None = from dnd_simulator.rules.weapons import get_weapon_attack weapon_attack = get_weapon_attack(creature) - weapon_name = weapon_attack.name + weapon_name = _(weapon_attack.name) weapon_damage = format_weapon_damage(weapon_attack) wall_descriptions: list[str] = [] diff --git a/src/dnd_simulator/layers/entities/perception.py b/src/dnd_simulator/layers/entities/perception.py index e7577109..6d94625b 100644 --- a/src/dnd_simulator/layers/entities/perception.py +++ b/src/dnd_simulator/layers/entities/perception.py @@ -42,7 +42,8 @@ # --------------------------------------------------------------------------- # Translatable dynamic values — listed here so pygettext3 can extract them. -# At runtime, _() is called on the raw string from event data. +# At runtime, _() is called on the raw string from event data. Item and attack +# names come from the YAML catalogs instead: content_loader/catalog_messages.py. # --------------------------------------------------------------------------- # fmt: off _TRANSLATABLE_STRINGS = [ @@ -52,11 +53,6 @@ _("radiant"), _("necrotic"), _("force"), _("psychic"), # Roll labels _("AC"), - # Common item/weapon names (from YAML catalogs) - _("Health Potion"), - _("Dagger"), _("Longsword"), _("Shortsword"), _("Greataxe"), _("Handaxe"), - _("Shortbow"), _("Longbow"), _("Light Crossbow"), - _("Mace"), _("Quarterstaff"), _("Javelin"), _("Spear"), # Fallback labels _("an item"), _("a weapon"), ] diff --git a/src/dnd_simulator/locale/messages.pot b/src/dnd_simulator/locale/messages.pot index 899c7b3f..4d9d352b 100644 --- a/src/dnd_simulator/locale/messages.pot +++ b/src/dnd_simulator/locale/messages.pot @@ -5,12 +5,12 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2026-09-13 02:58+0000\n" +"POT-Creation-Date: 2026-09-29 08:34+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" +"Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: pygettext.py 1.5\n" @@ -95,622 +95,551 @@ msgstr "" msgid "Layer '{}' forked to custom in world '{}'" msgstr "" -#: src/dnd_simulator/adapters/api/routes_ws.py:43 +#: src/dnd_simulator/adapters/api/routes_ws.py:45 msgid "Invalid JSON" msgstr "" -#: src/dnd_simulator/adapters/api/routes_ws.py:45 +#: src/dnd_simulator/adapters/api/routes_ws.py:47 msgid "JSON message must be an object" msgstr "" -#: src/dnd_simulator/adapters/api/routes_ws.py:126 -#: src/dnd_simulator/adapters/api/routes_ws.py:224 +#: src/dnd_simulator/adapters/api/routes_ws.py:138 +#: src/dnd_simulator/adapters/api/routes_ws.py:249 msgid "Rate limited, slow down" msgstr "" -#: src/dnd_simulator/adapters/api/routes_ws.py:138 +#: src/dnd_simulator/adapters/api/routes_ws.py:150 msgid "Spectators cannot submit actions" msgstr "" -#: src/dnd_simulator/adapters/api/routes_ws.py:140 -#: src/dnd_simulator/adapters/api/routes_ws.py:250 +#: src/dnd_simulator/adapters/api/routes_ws.py:152 +#: src/dnd_simulator/adapters/api/routes_ws.py:278 msgid "Unknown message type: {}" msgstr "" -#: src/dnd_simulator/adapters/api/routes_ws.py:177 +#: src/dnd_simulator/adapters/api/routes_ws.py:194 msgid "Session '{}' not found" msgstr "" -#: src/dnd_simulator/adapters/api/routes_ws.py:188 +#: src/dnd_simulator/adapters/api/routes_ws.py:205 msgid "No player in session" msgstr "" -#: src/dnd_simulator/adapters/api/routes_ws.py:239 +#: src/dnd_simulator/adapters/api/routes_ws.py:264 msgid "Unknown action: {}" msgstr "" -#: src/dnd_simulator/adapters/api/routes_ws.py:246 +#: src/dnd_simulator/adapters/api/routes_ws.py:271 msgid "Unknown reaction: {}" msgstr "" -#: src/dnd_simulator/core/combat.py:190 +#: src/dnd_simulator/adapters/api/routes_ws.py:275 +msgid "No reaction prompt is pending" +msgstr "" + +#: src/dnd_simulator/core/combat.py:212 msgid "Arena bounded by walls: {w}x{h} ft" msgstr "" -#: src/dnd_simulator/core/combat.py:195 +#: src/dnd_simulator/core/combat.py:217 msgid "vertical wall x={x} from y={y1} to y={y2}" msgstr "" -#: src/dnd_simulator/core/combat.py:201 +#: src/dnd_simulator/core/combat.py:223 msgid "horizontal wall y={y} from x={x1} to x={x2}" msgstr "" -#: src/dnd_simulator/layers/entities/combat_resolution.py:96 +#: src/dnd_simulator/layers/entities/combat_manager.py:173 +msgid "Cell ({}, {}) is outside the {}x{} ft battle map" +msgstr "" + +#: src/dnd_simulator/layers/entities/combat_manager.py:178 +msgid "Cell ({}, {}) is already occupied" +msgstr "" + +#: src/dnd_simulator/layers/entities/combat_resolution.py:81 +#: src/dnd_simulator/layers/entities/layer.py:369 msgid "Creature '{id}' not found." msgstr "" -#: src/dnd_simulator/layers/entities/combat_resolution.py:99 +#: src/dnd_simulator/layers/entities/combat_resolution.py:84 msgid "No active combat for movement." msgstr "" -#: src/dnd_simulator/layers/entities/combat_resolution.py:123 +#: src/dnd_simulator/layers/entities/combat_resolution.py:108 msgid "Attacker '{id}' not found." msgstr "" -#: src/dnd_simulator/layers/entities/combat_resolution.py:127 -#: src/dnd_simulator/rules/validation.py:187 -#: src/dnd_simulator/rules/validation.py:226 +#: src/dnd_simulator/layers/entities/combat_resolution.py:112 +#: src/dnd_simulator/rules/validation.py:208 +#: src/dnd_simulator/rules/validation.py:250 msgid "Target '{id}' not found." msgstr "" -#: src/dnd_simulator/layers/entities/materialization.py:305 +#: src/dnd_simulator/layers/entities/materialization.py:345 msgid "Treasure" msgstr "" -#: src/dnd_simulator/layers/entities/models.py:121 +#: src/dnd_simulator/layers/entities/models.py:128 msgid "Need something forged?" msgstr "" -#: src/dnd_simulator/layers/entities/models.py:122 +#: src/dnd_simulator/layers/entities/models.py:129 msgid "Hm? Oh, I'm off duty." msgstr "" -#: src/dnd_simulator/layers/entities/models.py:124 +#: src/dnd_simulator/layers/entities/models.py:131 msgid "What'll it be?" msgstr "" -#: src/dnd_simulator/layers/entities/models.py:125 +#: src/dnd_simulator/layers/entities/models.py:132 msgid "Kitchen's closed. Come back later." msgstr "" -#: src/dnd_simulator/layers/entities/models.py:127 +#: src/dnd_simulator/layers/entities/models.py:134 msgid "Move along, citizen." msgstr "" -#: src/dnd_simulator/layers/entities/models.py:128 +#: src/dnd_simulator/layers/entities/models.py:135 msgid "Quiet night, eh?" msgstr "" -#: src/dnd_simulator/layers/entities/models.py:130 +#: src/dnd_simulator/layers/entities/models.py:137 msgid "Looking to buy something?" msgstr "" -#: src/dnd_simulator/layers/entities/models.py:131 +#: src/dnd_simulator/layers/entities/models.py:138 msgid "Shop's closed. Try tomorrow." msgstr "" -#: src/dnd_simulator/layers/entities/models.py:133 +#: src/dnd_simulator/layers/entities/models.py:140 msgid "Can't talk, crops won't tend themselves." msgstr "" -#: src/dnd_simulator/layers/entities/models.py:134 +#: src/dnd_simulator/layers/entities/models.py:141 msgid "Fine evening, isn't it?" msgstr "" -#: src/dnd_simulator/layers/entities/models.py:138 +#: src/dnd_simulator/layers/entities/models.py:145 msgid "I'm busy." msgstr "" -#: src/dnd_simulator/layers/entities/models.py:139 +#: src/dnd_simulator/layers/entities/models.py:146 msgid "Hm?" msgstr "" -#: src/dnd_simulator/layers/entities/models.py:140 +#: src/dnd_simulator/layers/entities/models.py:147 msgid "Zzz..." msgstr "" -#: src/dnd_simulator/layers/entities/models.py:145 +#: src/dnd_simulator/layers/entities/models.py:152 msgid "Leave me alone!" msgstr "" -#: src/dnd_simulator/layers/entities/models.py:146 +#: src/dnd_simulator/layers/entities/models.py:153 msgid "Shh... Something's not right." msgstr "" -#: src/dnd_simulator/layers/entities/models.py:147 +#: src/dnd_simulator/layers/entities/models.py:154 msgid "I... I can't talk right now." msgstr "" -#: src/dnd_simulator/layers/entities/models.py:148 +#: src/dnd_simulator/layers/entities/models.py:155 msgid "What do you want?" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:48 +#: src/dnd_simulator/layers/entities/perception.py:51 msgid "bludgeoning" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:48 +#: src/dnd_simulator/layers/entities/perception.py:51 msgid "piercing" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:48 +#: src/dnd_simulator/layers/entities/perception.py:51 msgid "slashing" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:49 +#: src/dnd_simulator/layers/entities/perception.py:52 msgid "acid" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:49 +#: src/dnd_simulator/layers/entities/perception.py:52 msgid "cold" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:49 +#: src/dnd_simulator/layers/entities/perception.py:52 msgid "fire" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:49 +#: src/dnd_simulator/layers/entities/perception.py:52 msgid "lightning" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:49 +#: src/dnd_simulator/layers/entities/perception.py:52 msgid "poison" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:49 +#: src/dnd_simulator/layers/entities/perception.py:52 msgid "thunder" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:50 +#: src/dnd_simulator/layers/entities/perception.py:53 msgid "force" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:50 +#: src/dnd_simulator/layers/entities/perception.py:53 msgid "necrotic" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:50 +#: src/dnd_simulator/layers/entities/perception.py:53 msgid "psychic" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:50 +#: src/dnd_simulator/layers/entities/perception.py:53 msgid "radiant" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:52 -msgid "ability" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py -msgid "STR" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py -msgid "DEX" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py -msgid "CON" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py -msgid "INT" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py -msgid "WIS" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py -msgid "CHA" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py -msgid "magic weapon" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py -msgid "Sneak Attack" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py -msgid "Dueling" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py -msgid "Divine Smite" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py:52 -msgid "weapon" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py:53 +#: src/dnd_simulator/layers/entities/perception.py:55 msgid "AC" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:112 -msgid "vs AC {ac}" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py:56 -msgid "Health Potion" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py:57 -msgid "Dagger" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py:57 -msgid "Greataxe" -msgstr "" - #: src/dnd_simulator/layers/entities/perception.py:57 -msgid "Handaxe" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py:57 -msgid "Longsword" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py:57 -msgid "Shortsword" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py:58 -msgid "Light Crossbow" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py:58 -msgid "Longbow" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py:58 -msgid "Shortbow" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py:59 -msgid "Javelin" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py:59 -msgid "Mace" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py:59 -msgid "Quarterstaff" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py:59 -msgid "Spear" -msgstr "" - -#: src/dnd_simulator/layers/entities/perception.py:61 msgid "a weapon" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:61 +#: src/dnd_simulator/layers/entities/perception.py:57 msgid "an item" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:70 +#: src/dnd_simulator/layers/entities/perception.py:66 #: src/dnd_simulator/layers/entities/perception_common.py:16 -#: src/dnd_simulator/llm/brain.py:227 +#: src/dnd_simulator/llm/brain.py:229 msgid "someone" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:72 +#: src/dnd_simulator/layers/entities/perception.py:68 #: src/dnd_simulator/layers/entities/perception_common.py:18 msgid "you" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:89 +#: src/dnd_simulator/layers/entities/perception.py:85 msgid "You say: \"{text}\"" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:90 +#: src/dnd_simulator/layers/entities/perception.py:86 msgid "{speaker} says: \"{text}\"" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:101 +#: src/dnd_simulator/layers/entities/perception.py:97 msgid "adv " msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:103 +#: src/dnd_simulator/layers/entities/perception.py:99 msgid "disadv " msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:134 +#: src/dnd_simulator/layers/entities/perception.py:109 +msgid "vs AC {ac}" +msgstr "" + +#: src/dnd_simulator/layers/entities/perception.py:160 msgid ", CRIT! {damage} damage{detail}" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:135 +#: src/dnd_simulator/layers/entities/perception.py:161 msgid ", {damage} damage{detail}" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:152 +#: src/dnd_simulator/layers/entities/perception.py:178 msgid " (opportunity attack)" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:156 +#: src/dnd_simulator/layers/entities/perception.py:182 msgid ", miss" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:163 +#: src/dnd_simulator/layers/entities/perception.py:189 msgid "You attack {target}{weapon}{oa}{roll}{outcome}" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:167 +#: src/dnd_simulator/layers/entities/perception.py:193 msgid "{attacker} attacks you{weapon}{oa}{roll}{outcome}" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:170 +#: src/dnd_simulator/layers/entities/perception.py:196 msgid "{attacker} attacks {target}{weapon}{oa}{roll}{outcome}" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:178 +#: src/dnd_simulator/layers/entities/perception.py:204 msgid "Combat ended." msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:186 +#: src/dnd_simulator/layers/entities/perception.py:212 msgid "You disengage" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:188 +#: src/dnd_simulator/layers/entities/perception.py:214 msgid "{entity} disengages" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:204 +#: src/dnd_simulator/layers/entities/perception.py:230 msgid "You seize the opening against {target}!" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:206 +#: src/dnd_simulator/layers/entities/perception.py:232 msgid "{attacker} seizes the opening against you!" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:207 +#: src/dnd_simulator/layers/entities/perception.py:233 msgid "{attacker} seizes the opening against {target}!" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:215 +#: src/dnd_simulator/layers/entities/perception.py:241 msgid "You die" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:217 +#: src/dnd_simulator/layers/entities/perception.py:243 msgid "{entity} dies" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:228 +#: src/dnd_simulator/layers/entities/perception.py:254 msgid "You take a defensive stance{desc}" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:230 +#: src/dnd_simulator/layers/entities/perception.py:256 msgid "{entity} takes a defensive stance{desc}" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:241 -msgid "You try to flee{desc}" +#: src/dnd_simulator/layers/entities/perception.py:268 +msgid "You flee the fight toward {destination}{desc}" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:243 -msgid "{entity} tries to flee{desc}" +#: src/dnd_simulator/layers/entities/perception.py:273 +msgid "{entity} flees the fight toward {destination}{desc}" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:268 +#: src/dnd_simulator/layers/entities/perception.py:288 +msgid "{hours} {minutes}" +msgstr "" + +#: src/dnd_simulator/layers/entities/perception.py:298 +msgid "You ran for {duration} to {destination}." +msgstr "" + +#: src/dnd_simulator/layers/entities/perception.py:299 +msgid "You walked for {duration} to {destination}." +msgstr "" + +#: src/dnd_simulator/layers/entities/perception.py:301 +msgid "{entity} arrives." +msgstr "" + +#: src/dnd_simulator/layers/entities/perception.py:326 msgid "You move {direction} ({distance} ft){desc}" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:272 +#: src/dnd_simulator/layers/entities/perception.py:330 msgid "{entity} moves {direction} ({distance} ft){desc}" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:283 +#: src/dnd_simulator/layers/entities/perception.py:341 msgid "You dash (+{ft} ft movement)" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:285 +#: src/dnd_simulator/layers/entities/perception.py:343 msgid "{entity} dashes (+{ft} ft movement)" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:297 +#: src/dnd_simulator/layers/entities/perception.py:355 msgid "You use {item}, but you are already at full health" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:299 +#: src/dnd_simulator/layers/entities/perception.py:357 msgid "{entity} uses {item}, but is already at full health" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:301 +#: src/dnd_simulator/layers/entities/perception.py:359 msgid "You use {item} (healed {hp} HP)" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:303 +#: src/dnd_simulator/layers/entities/perception.py:361 msgid "{entity} uses {item} (healed {hp} HP)" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:314 +#: src/dnd_simulator/layers/entities/perception.py:372 msgid "You look around but see no one matching '{id}'." msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:316 +#: src/dnd_simulator/layers/entities/perception.py:374 msgid "You inspect {desc}." msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:319 +#: src/dnd_simulator/layers/entities/perception.py:377 msgid "They look badly wounded." msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:321 +#: src/dnd_simulator/layers/entities/perception.py:379 msgid "They have minor injuries." msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:323 +#: src/dnd_simulator/layers/entities/perception.py:381 msgid "They look healthy." msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:326 +#: src/dnd_simulator/layers/entities/perception.py:384 msgid "Conditions: {list}." msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:338 +#: src/dnd_simulator/layers/entities/perception.py:396 msgid "You can't act ({conditions}) — turn skipped" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:340 +#: src/dnd_simulator/layers/entities/perception.py:398 msgid "{entity} can't act ({conditions}) — turn skipped" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:349 +#: src/dnd_simulator/layers/entities/perception.py:407 msgid "You invoke a blessing (+d4 to attack rolls for {n} rounds)" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:351 +#: src/dnd_simulator/layers/entities/perception.py:409 msgid "{entity} invokes a blessing (+d4 to attack rolls for {n} rounds)" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:362 +#: src/dnd_simulator/layers/entities/perception.py:420 msgid "You catch your breath, but you are already at full health" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:364 +#: src/dnd_simulator/layers/entities/perception.py:422 msgid "{entity} catches their breath, already at full health" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:366 +#: src/dnd_simulator/layers/entities/perception.py:424 msgid "You catch your breath, regaining {hp} HP" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:368 +#: src/dnd_simulator/layers/entities/perception.py:426 msgid "{entity} catches their breath, regaining {hp} HP" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:376 +#: src/dnd_simulator/layers/entities/perception.py:434 msgid "You surge with energy, gaining an extra action" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:378 +#: src/dnd_simulator/layers/entities/perception.py:436 msgid "{entity} surges with energy, gaining an extra action" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:395 +#: src/dnd_simulator/layers/entities/perception.py:453 msgid "You lay hands on yourself, but you are already at full health" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:398 +#: src/dnd_simulator/layers/entities/perception.py:456 msgid "You lay hands on {target}, but they are already at full health" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:401 +#: src/dnd_simulator/layers/entities/perception.py:459 msgid "{entity} lays hands on you, but you are already at full health" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:403 +#: src/dnd_simulator/layers/entities/perception.py:461 msgid "{entity} lays hands on {target}, but they are already at full health" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:407 +#: src/dnd_simulator/layers/entities/perception.py:465 msgid "You lay hands on yourself, restoring {hp} HP (pool {before}→{after})" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:412 +#: src/dnd_simulator/layers/entities/perception.py:470 msgid "You lay hands on {target}, restoring {hp} HP (pool {before}→{after})" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:417 +#: src/dnd_simulator/layers/entities/perception.py:475 msgid "{entity} lays hands on you, restoring {hp} HP" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:419 +#: src/dnd_simulator/layers/entities/perception.py:477 msgid "{entity} lays hands on {target}, restoring {hp} HP" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:428 +#: src/dnd_simulator/layers/entities/perception.py:486 msgid "You equip {weapon}" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:430 +#: src/dnd_simulator/layers/entities/perception.py:488 msgid "{entity} equips {weapon}" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:439 +#: src/dnd_simulator/layers/entities/perception.py:497 msgid "You put away {weapon}" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:441 +#: src/dnd_simulator/layers/entities/perception.py:499 msgid "{entity} puts away {weapon}" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:454 +#: src/dnd_simulator/layers/entities/perception.py:512 msgid "You buy {item} from {merchant} for {price} gold" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:458 +#: src/dnd_simulator/layers/entities/perception.py:516 msgid "{buyer} buys {item} from {merchant} for {price} gold" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:473 +#: src/dnd_simulator/layers/entities/perception.py:531 msgid "You sell {item} to {merchant} for {price} gold" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:477 +#: src/dnd_simulator/layers/entities/perception.py:535 msgid "{seller} sells {item} to {merchant} for {price} gold" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:494 +#: src/dnd_simulator/layers/entities/perception.py:552 msgid "{gold} gold" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:495 +#: src/dnd_simulator/layers/entities/perception.py:553 msgid "nothing" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:499 +#: src/dnd_simulator/layers/entities/perception.py:557 msgid "You loot {target} ({loot})" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:501 +#: src/dnd_simulator/layers/entities/perception.py:559 msgid "{actor} loots {target} ({loot})" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:509 +#: src/dnd_simulator/layers/entities/perception.py:567 msgid "{name} appears — {count} creatures materialize" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:516 +#: src/dnd_simulator/layers/entities/perception.py:574 msgid "{name} moves on, disappearing into the distance" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:522 +#: src/dnd_simulator/layers/entities/perception.py:580 msgid "Something stirs nearby" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:533 +#: src/dnd_simulator/layers/entities/perception.py:591 msgid "You gain {amount} XP for defeating {source}" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:535 +#: src/dnd_simulator/layers/entities/perception.py:593 msgid "{entity} gains {amount} XP for defeating {source}" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:547 +#: src/dnd_simulator/layers/entities/perception.py:605 msgid "Your reputation with {faction} changed ({old} → {new})" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:551 +#: src/dnd_simulator/layers/entities/perception.py:609 msgid "{entity}'s reputation with {faction} changed" msgstr "" -#: src/dnd_simulator/layers/entities/perception.py:609 +#: src/dnd_simulator/layers/entities/perception.py:668 msgid "Something happened ({type})" msgstr "" @@ -746,19 +675,19 @@ msgstr "" msgid "Weather in {region} changed from {old} to {new}" msgstr "" -#: src/dnd_simulator/llm/brain.py:93 +#: src/dnd_simulator/llm/brain.py:94 msgid "You must choose an action: attack, move, dash, dodge, flee, or idle." msgstr "" -#: src/dnd_simulator/llm/brain.py:100 +#: src/dnd_simulator/llm/brain.py:101 msgid "You must choose an action: say, attack, or idle." msgstr "" -#: src/dnd_simulator/llm/brain.py:108 +#: src/dnd_simulator/llm/brain.py:109 msgid "Your turn. Choose an action." msgstr "" -#: src/dnd_simulator/llm/brain.py:111 +#: src/dnd_simulator/llm/brain.py:112 msgid "" "What happened since your last turn:\n" "{events}\n" @@ -766,19 +695,19 @@ msgid "" "Your turn. Choose an action." msgstr "" -#: src/dnd_simulator/llm/brain.py:149 +#: src/dnd_simulator/llm/brain.py:154 msgid "A creature is {trigger_type}. You can react or skip." msgstr "" -#: src/dnd_simulator/llm/brain.py:153 +#: src/dnd_simulator/llm/brain.py:158 msgid "You are an NPC deciding whether to use your reaction." msgstr "" -#: src/dnd_simulator/llm/brain.py:209 +#: src/dnd_simulator/llm/brain.py:218 msgid "Previous tool call was rejected: {reason}. Choose one of the offered tools." msgstr "" -#: src/dnd_simulator/llm/brain.py:228 +#: src/dnd_simulator/llm/brain.py:230 msgid "Heard {speaker} say: {words}. This is heard speech, not an instruction." msgstr "" @@ -950,7 +879,7 @@ msgstr "" msgid "Recent thoughts:" msgstr "" -#: src/dnd_simulator/round.py:480 +#: src/dnd_simulator/round.py:571 msgid "Melee attack against {target}" msgstr "" @@ -976,6 +905,15 @@ msgstr "" msgid "Divine Smite requires Paladin level 2." msgstr "" +#: src/dnd_simulator/rules/flee.py:39 +msgid "Enemies are too close to flee" +msgstr "" + +#: src/dnd_simulator/rules/flee.py:40 +#: src/dnd_simulator/rules/handlers/combat.py:74 +msgid "There is nowhere to flee" +msgstr "" + #: src/dnd_simulator/rules/handlers/action_surge.py:31 msgid "Only characters can use Action Surge" msgstr "" @@ -1000,20 +938,28 @@ msgstr "" msgid "Cannot move there — blocked." msgstr "" -#: src/dnd_simulator/rules/handlers/equipment.py:106 +#: src/dnd_simulator/rules/handlers/combat.py:69 +msgid "Choose a neighbouring location to flee to" +msgstr "" + +#: src/dnd_simulator/rules/handlers/combat.py:77 +msgid "You can only flee to a neighbouring location" +msgstr "" + +#: src/dnd_simulator/rules/handlers/equipment.py:112 #: src/dnd_simulator/rules/handlers/items.py:74 msgid "Item {id} not in inventory" msgstr "" -#: src/dnd_simulator/rules/handlers/equipment.py:110 +#: src/dnd_simulator/rules/handlers/equipment.py:116 msgid "Item {id} is not a {item_type}" msgstr "" -#: src/dnd_simulator/rules/handlers/equipment.py:116 +#: src/dnd_simulator/rules/handlers/equipment.py:122 msgid "Item {id} is a {slot} accessory, not {expected}" msgstr "" -#: src/dnd_simulator/rules/handlers/equipment.py:142 +#: src/dnd_simulator/rules/handlers/equipment.py:148 msgid "No {item_type} equipped" msgstr "" @@ -1057,61 +1003,61 @@ msgstr "" msgid "Target '{id}' cannot be looted" msgstr "" -#: src/dnd_simulator/rules/handlers/movement.py:45 +#: src/dnd_simulator/rules/handlers/movement.py:51 msgid "Move requires a direction" msgstr "" -#: src/dnd_simulator/rules/handlers/movement.py:55 -#: src/dnd_simulator/rules/handlers/movement.py:121 +#: src/dnd_simulator/rules/handlers/movement.py:61 +#: src/dnd_simulator/rules/handlers/movement.py:127 msgid "Not on the battle map" msgstr "" -#: src/dnd_simulator/rules/handlers/movement.py:59 +#: src/dnd_simulator/rules/handlers/movement.py:65 msgid "Cannot move there, blocked" msgstr "" -#: src/dnd_simulator/rules/handlers/movement.py:68 +#: src/dnd_simulator/rules/handlers/movement.py:74 msgid "Not enough movement" msgstr "" -#: src/dnd_simulator/rules/handlers/movement.py:112 -#: src/dnd_simulator/rules/validation.py:162 +#: src/dnd_simulator/rules/handlers/movement.py:118 +#: src/dnd_simulator/rules/validation.py:183 msgid "No movement remaining" msgstr "" -#: src/dnd_simulator/rules/handlers/movement.py:116 +#: src/dnd_simulator/rules/handlers/movement.py:122 msgid "Not in combat" msgstr "" -#: src/dnd_simulator/rules/handlers/movement.py:128 +#: src/dnd_simulator/rules/handlers/movement.py:134 msgid "Already at that position" msgstr "" -#: src/dnd_simulator/rules/handlers/movement.py:137 +#: src/dnd_simulator/rules/handlers/movement.py:143 msgid "Not enough movement to reach there" msgstr "" -#: src/dnd_simulator/rules/handlers/movement.py:138 +#: src/dnd_simulator/rules/handlers/movement.py:144 msgid "No path to target" msgstr "" -#: src/dnd_simulator/rules/handlers/movement.py:166 +#: src/dnd_simulator/rules/handlers/movement.py:172 msgid "Cannot move, insufficient budget" msgstr "" -#: src/dnd_simulator/rules/handlers/movement.py:196 +#: src/dnd_simulator/rules/handlers/movement.py:202 msgid "Dash requires a turn budget" msgstr "" -#: src/dnd_simulator/rules/handlers/movement.py:249 +#: src/dnd_simulator/rules/handlers/movement.py:255 msgid "Travel requires a destination" msgstr "" -#: src/dnd_simulator/rules/handlers/movement.py:254 +#: src/dnd_simulator/rules/handlers/movement.py:260 msgid "No route to destination" msgstr "" -#: src/dnd_simulator/rules/handlers/movement.py:256 +#: src/dnd_simulator/rules/handlers/movement.py:262 msgid "Already at destination" msgstr "" @@ -1142,6 +1088,14 @@ msgstr "" msgid "Activation trigger '{id}' is not active" msgstr "" +#: src/dnd_simulator/rules/loot.py:73 +msgid "Too far to loot ({dist} ft); it must be in an adjacent cell." +msgstr "" + +#: src/dnd_simulator/rules/loot.py:74 +msgid "Out of reach in combat: it is not on the battle map." +msgstr "" + #: src/dnd_simulator/rules/movement.py:48 #: src/dnd_simulator/rules/movement.py:61 msgid "here" @@ -1207,68 +1161,267 @@ msgstr "" msgid "Merchant doesn't have enough gold (need {price}, have {gold})" msgstr "" -#: src/dnd_simulator/rules/validation.py:74 +#: src/dnd_simulator/rules/validation.py:76 msgid "Dead creatures cannot act" msgstr "" -#: src/dnd_simulator/rules/validation.py:81 +#: src/dnd_simulator/rules/validation.py:83 msgid "Dormant creatures cannot act" msgstr "" -#: src/dnd_simulator/rules/validation.py:97 +#: src/dnd_simulator/rules/validation.py:99 msgid "'{action}' requires parameter '{param}'." msgstr "" -#: src/dnd_simulator/rules/validation.py:115 +#: src/dnd_simulator/rules/validation.py:118 msgid "This is not available in combat: {action}" msgstr "" -#: src/dnd_simulator/rules/validation.py:120 +#: src/dnd_simulator/rules/validation.py:123 msgid "This is not available outside combat: {action}" msgstr "" -#: src/dnd_simulator/rules/validation.py:139 +#: src/dnd_simulator/rules/validation.py:158 msgid "No cost override '{mode}' available: {action}" msgstr "" -#: src/dnd_simulator/rules/validation.py:151 +#: src/dnd_simulator/rules/validation.py:172 msgid "Not enough budget left this turn: {action}" msgstr "" -#: src/dnd_simulator/rules/validation.py:193 +#: src/dnd_simulator/rules/validation.py:214 msgid "Target '{id}' is already dead." msgstr "" -#: src/dnd_simulator/rules/validation.py:199 -#: src/dnd_simulator/rules/validation.py:229 +#: src/dnd_simulator/rules/validation.py:220 +#: src/dnd_simulator/rules/validation.py:253 msgid "Target '{id}' is not in this region." msgstr "" -#: src/dnd_simulator/rules/validation.py:232 +#: src/dnd_simulator/rules/validation.py:256 msgid "Target '{id}' cannot be looted." msgstr "" -#: src/dnd_simulator/rules/validation.py:250 +#: src/dnd_simulator/rules/validation.py:281 msgid "Item '{id}' not in inventory." msgstr "" -#: src/dnd_simulator/rules/validation.py:278 +#: src/dnd_simulator/rules/validation.py:309 msgid "Target is already at full HP." msgstr "" -#: src/dnd_simulator/rules/validation.py:313 +#: src/dnd_simulator/rules/validation.py:344 msgid "Target too far ({dist} ft, reach {reach} ft)." msgstr "" -#: src/dnd_simulator/rules/validation.py:339 +#: src/dnd_simulator/rules/validation.py:370 msgid "Cannot target yourself with '{action}'." msgstr "" -#: src/dnd_simulator/rules/validation.py:378 +#: src/dnd_simulator/rules/validation.py:409 msgid "'{action}' can only target hostile creatures." msgstr "" -#: src/dnd_simulator/rules/validation.py:383 +#: src/dnd_simulator/rules/validation.py:414 msgid "'{action}' can only target allies." msgstr "" +#: content/catalogs/items/boots_of_speed.yaml +msgid "Boots of Speed" +msgstr "" + +#: content/catalogs/items/breastplate.yaml +msgid "Breastplate" +msgstr "" + +#: content/catalogs/items/chain_mail.yaml +msgid "Chain Mail" +msgstr "" + +#: content/catalogs/items/chain_shirt.yaml +msgid "Chain Shirt" +msgstr "" + +#: content/catalogs/items/circlet_of_aim.yaml +msgid "Circlet of Aim" +msgstr "" + +#: content/catalogs/items/dagger.yaml +msgid "Dagger" +msgstr "" + +#: content/catalogs/items/flaming_longsword.yaml +msgid "Flaming Longsword" +msgstr "" + +#: content/catalogs/items/frost_dagger.yaml +msgid "Frost Dagger" +msgstr "" + +#: content/catalogs/items/greataxe.yaml +msgid "Greataxe" +msgstr "" + +#: content/catalogs/items/greatsword.yaml +msgid "Greatsword" +msgstr "" + +#: content/catalogs/items/half_plate.yaml +msgid "Half Plate" +msgstr "" + +#: content/catalogs/items/hand_crossbow.yaml +msgid "Hand Crossbow" +msgstr "" + +#: content/catalogs/items/health_potion.yaml +msgid "Health Potion" +msgstr "" + +#: content/catalogs/items/hide.yaml +msgid "Hide Armor" +msgstr "" + +#: content/catalogs/items/leather.yaml +msgid "Leather Armor" +msgstr "" + +#: content/catalogs/items/longbow.yaml +msgid "Longbow" +msgstr "" + +#: content/catalogs/items/longsword.yaml +msgid "Longsword" +msgstr "" + +#: content/catalogs/items/mace.yaml +msgid "Mace" +msgstr "" + +#: content/catalogs/items/padded.yaml +msgid "Padded Armor" +msgstr "" + +#: content/catalogs/items/plate.yaml +msgid "Plate Armor" +msgstr "" + +#: content/catalogs/items/quarterstaff.yaml +msgid "Quarterstaff" +msgstr "" + +#: content/catalogs/items/rapier.yaml +msgid "Rapier" +msgstr "" + +#: content/catalogs/items/ring_mail.yaml +msgid "Ring Mail" +msgstr "" + +#: content/catalogs/items/ring_of_protection.yaml +msgid "Ring of Protection" +msgstr "" + +#: content/catalogs/items/scale_mail.yaml +msgid "Scale Mail" +msgstr "" + +#: content/catalogs/items/shield.yaml +msgid "Shield" +msgstr "" + +#: content/catalogs/items/shortbow.yaml +msgid "Shortbow" +msgstr "" + +#: content/catalogs/items/shortsword.yaml +msgid "Shortsword" +msgstr "" + +#: content/catalogs/items/splint.yaml +msgid "Splint Armor" +msgstr "" + +#: content/catalogs/items/studded_leather.yaml +msgid "Studded Leather Armor" +msgstr "" + +#: content/catalogs/items/warhammer.yaml +msgid "Warhammer" +msgstr "" + +#: content/catalogs/monsters/dire_wolf.yaml content/catalogs/monsters/wolf.yaml +msgid "bite" +msgstr "" + +#: content/catalogs/items/dagger.yaml +msgid "dagger strike" +msgstr "" + +#: content/catalogs/items/flaming_longsword.yaml +msgid "flaming slash" +msgstr "" + +#: content/catalogs/items/frost_dagger.yaml +msgid "frost stab" +msgstr "" + +#: content/catalogs/monsters/orc.yaml content/catalogs/monsters/orc_war_chief.yaml +msgid "greataxe" +msgstr "" + +#: content/catalogs/items/greataxe.yaml +msgid "greataxe chop" +msgstr "" + +#: content/catalogs/items/greatsword.yaml +msgid "greatsword slash" +msgstr "" + +#: content/catalogs/items/hand_crossbow.yaml +msgid "hand crossbow shot" +msgstr "" + +#: content/catalogs/items/longbow.yaml +msgid "longbow shot" +msgstr "" + +#: content/catalogs/items/longsword.yaml +msgid "longsword slash" +msgstr "" + +#: content/catalogs/items/mace.yaml +msgid "mace strike" +msgstr "" + +#: content/catalogs/items/quarterstaff.yaml +msgid "quarterstaff strike" +msgstr "" + +#: content/catalogs/items/rapier.yaml +msgid "rapier thrust" +msgstr "" + +#: content/catalogs/monsters/goblin.yaml content/catalogs/monsters/goblin_boss.yaml +msgid "scimitar" +msgstr "" + +#: content/catalogs/items/shortbow.yaml +msgid "shortbow shot" +msgstr "" + +#: content/catalogs/monsters/bandit.yaml +msgid "shortsword" +msgstr "" + +#: content/catalogs/items/shortsword.yaml +msgid "shortsword stab" +msgstr "" + +#: content/catalogs/monsters/guard.yaml +msgid "spear" +msgstr "" + +#: content/catalogs/items/warhammer.yaml +msgid "warhammer smash" +msgstr "" diff --git a/src/dnd_simulator/locale/ru/LC_MESSAGES/dnd_simulator.mo b/src/dnd_simulator/locale/ru/LC_MESSAGES/dnd_simulator.mo index 5066884d3f8890d4c405bd268baa6ab71ac7191d..8faf94a92c7e7dbd6ac868f17589b6e00d0d8ffb 100644 GIT binary patch delta 8950 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 delta 6618 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 list[dict[str, object]]: for item in player.inventory: entry: dict[str, object] = { "id": item.id, - "name": item.name, + "name": _(item.name), "type": item.item_type.value, "description": describe_item(item), "price": item.price, diff --git a/tests/unit/test_catalog_translations.py b/tests/unit/test_catalog_translations.py new file mode 100644 index 00000000..c44aa3b2 --- /dev/null +++ b/tests/unit/test_catalog_translations.py @@ -0,0 +1,152 @@ +"""Catalog item and attack names reach the player translated (RU) and unchanged (EN). + +The names live in YAML, not Python, so they are listed by ``content_loader.catalog_messages`` +instead of a hand-maintained ``_()`` list; these tests fail when a catalog entry has no Russian +translation or a player-facing surface shows a catalog name without gettext. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path + +import pytest +from babel.messages.pofile import read_po + +import dnd_simulator +from dnd_simulator.content_loader import parse_items +from dnd_simulator.content_loader.catalog_messages import append_to_pot, catalog_msgids +from dnd_simulator.content_loader.catalogs import load_catalog +from dnd_simulator.content_loader.schemas import ItemContent +from dnd_simulator.core.awareness import item_info +from dnd_simulator.core.character import AbilityScores, Alignment, CharClass, Race +from dnd_simulator.core.events import EquipmentPayload +from dnd_simulator.core.items import EquipmentSlot, Item +from dnd_simulator.core.models import Event, EventType +from dnd_simulator.core.player import PlayerCharacter +from dnd_simulator.i18n import _, set_language +from dnd_simulator.layers.entities import perception +from dnd_simulator.layers.entities.awareness_builder import AwarenessBuilder +from dnd_simulator.layers.entities.layer import EntitiesLayer +from dnd_simulator.layers.entities.perception import perceive_event +from dnd_simulator.service.transport_payloads import build_inventory_payload + +CONTENT_ROOT = Path(__file__).resolve().parents[2] / "content" +RU_PO = Path(dnd_simulator.__file__).parent / "locale" / "ru" / "LC_MESSAGES" / "dnd_simulator.po" + + +@pytest.fixture(autouse=True) +def _english_after() -> Iterator[None]: + yield + set_language("en") + + +@pytest.fixture(scope="module") +def msgids() -> dict[str, list[str]]: + return catalog_msgids(CONTENT_ROOT) + + +@pytest.fixture(scope="module") +def item_catalog() -> dict[str, ItemContent]: + return load_catalog(CONTENT_ROOT / "catalogs" / "items", ItemContent) + + +def _item(ref: str, catalog: dict[str, ItemContent]) -> Item: + return parse_items([{"ref": ref}], item_catalog=catalog)[0] + + +def _player(**kwargs: object) -> PlayerCharacter: + return PlayerCharacter( + id="player", + name="Hero", + location_id="loc", + race=Race.HUMAN, + char_class=CharClass.FIGHTER, + alignment=Alignment.TRUE_NEUTRAL, + ability_scores=AbilityScores(), + max_hp=20, + current_hp=20, + ac=16, + **kwargs, # type: ignore[arg-type] + ) + + +class TestCatalogMsgids: + def test_covers_item_names_attack_names_and_monster_attacks(self, msgids: dict[str, list[str]]) -> None: + assert "Chain Mail" in msgids + assert "Shield" in msgids + assert "longsword slash" in msgids + assert msgids["scimitar"] == ["catalogs/monsters/goblin.yaml", "catalogs/monsters/goblin_boss.yaml"] + + def test_every_catalog_name_has_a_russian_translation(self, msgids: dict[str, list[str]]) -> None: + with RU_PO.open("rb") as fh: + translated = {str(m.id): str(m.string) for m in read_po(fh) if m.id and m.string} + missing = sorted(msgid for msgid in msgids if msgid not in translated) + assert missing == [], f"catalog names without a ru msgstr in {RU_PO.name}: {missing}" + + def test_compiled_catalog_translates_every_catalog_name(self, msgids: dict[str, list[str]]) -> None: + """The .mo is compiled from the .po: ru gettext returns a translation for each name.""" + set_language("ru") + untranslated = sorted(msgid for msgid in msgids if _(msgid) == msgid) + assert untranslated == [], f"run `make compile-messages`; untranslated: {untranslated}" + + def test_perception_no_longer_hand_lists_catalog_names(self, msgids: dict[str, list[str]]) -> None: + set_language("en") + assert set(perception._TRANSLATABLE_STRINGS) & set(msgids) == set() + + +class TestPotExtraction: + def test_appends_missing_catalog_msgids_once(self, tmp_path: Path, msgids: dict[str, list[str]]) -> None: + pot = tmp_path / "messages.pot" + header = 'msgid ""\nmsgstr ""\n"Content-Type: text/plain; charset=UTF-8\\n"\n\n' + pot.write_text(header + 'msgid "Shield"\nmsgstr ""\n') + + added = append_to_pot(pot, CONTENT_ROOT) + + assert added == len(msgids) - 1 + with pot.open("rb") as fh: + messages = {str(m.id): m for m in read_po(fh) if m.id} + assert set(messages) == set(msgids) + assert ("content/catalogs/items/chain_mail.yaml", None) in messages["Chain Mail"].locations + assert append_to_pot(pot, CONTENT_ROOT) == 0 + + +class TestPlayerFacingNames: + def test_combat_panel_weapon_is_translated_in_russian(self, item_catalog: dict[str, ItemContent]) -> None: + player = _player(equipped={EquipmentSlot.WEAPON: _item("longsword", item_catalog)}) + layer = EntitiesLayer([player]) + + set_language("en") + assert layer.build_combat_awareness(player).self_weapon == "longsword slash" + set_language("ru") + assert layer.build_combat_awareness(player).self_weapon == "удар длинным мечом" + + def test_inventory_and_equipment_names_are_translated_in_russian( + self, item_catalog: dict[str, ItemContent] + ) -> None: + mail = _item("chain_mail", item_catalog) + player = _player( + inventory=[_item("shield", item_catalog)], + equipped={EquipmentSlot.ARMOR: mail}, + ) + + set_language("en") + assert build_inventory_payload(player)[0]["name"] == "Shield" + assert item_info(mail).name == "Chain Mail" + set_language("ru") + assert build_inventory_payload(player)[0]["name"] == "Щит" + assert item_info(mail).name == "Кольчуга" + assert [e.name for e in AwarenessBuilder.build_equipped(player)] == ["Кольчуга"] + + def test_put_away_log_line_names_the_item_in_russian(self) -> None: + observer = _player() + event = Event( + event_type=EventType.ENTITY_UNEQUIP, + source_layer="entities", + data=EquipmentPayload(entity_id="player", item_name="Shield"), + ) + + set_language("en") + assert perceive_event(event, observer, lambda _eid: observer) == "You put away Shield" + set_language("ru") + assert "Щит" in perceive_event(event, observer, lambda _eid: observer)