Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,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
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/game/__tests__/ActionBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
70 changes: 70 additions & 0 deletions frontend/src/components/game/__tests__/ActionButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ActionBar />)
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(<ActionBar />)
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(<ActionBar />)
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(<ActionBar />)
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(
Expand Down
39 changes: 22 additions & 17 deletions frontend/src/components/game/action-bar/DirectionalDropdown.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -22,6 +23,7 @@ export function DirectionalDropdown({ name, description, enemies, disabled, open
const dataAttrs: Record<string, string> = {}
if (costType) dataAttrs["data-cost-type"] = costType
if (depleted) dataAttrs["data-depleted"] = ""
const labels = perceivedLabels(enemies, t)

return (
<div className="relative">
Expand All @@ -39,22 +41,25 @@ export function DirectionalDropdown({ name, description, enemies, disabled, open
</Button>
{openDropdown === name && (
<div className="absolute bottom-full left-0 z-10 mb-1 min-w-[180px] rounded border border-border bg-popover p-1 shadow-md">
{enemies.map((e) => (
<div key={e.id} className="flex gap-1">
<button
className="flex-1 rounded px-2 py-1 text-left text-xs hover:bg-accent"
onClick={() => sendAction(name, { toward: e.id })}
>
{t(towardKey, { target: e.id })}
</button>
<button
className="flex-1 rounded px-2 py-1 text-left text-xs hover:bg-accent"
onClick={() => sendAction(name, { away_from: e.id })}
>
{t(awayKey, { target: e.id })}
</button>
</div>
))}
{enemies.map((e) => {
const target = labels.get(e.id) ?? t("game:unknown_creature")
return (
<div key={e.id} className="flex gap-1">
<button
className="flex-1 rounded px-2 py-1 text-left text-xs hover:bg-accent"
onClick={() => sendAction(name, { toward: e.id })}
>
{t(towardKey, { target })}
</button>
<button
className="flex-1 rounded px-2 py-1 text-left text-xs hover:bg-accent"
onClick={() => sendAction(name, { away_from: e.id })}
>
{t(awayKey, { target })}
</button>
</div>
)
})}
</div>
)}
</div>
Expand Down
15 changes: 10 additions & 5 deletions frontend/src/components/game/action-bar/TargetDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand All @@ -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"
Expand Down Expand Up @@ -160,7 +165,7 @@ export function TargetDropdown({ name, description, nearby, scope, selfId, disab
<div className="absolute bottom-full left-0 z-10 mb-1 min-w-[200px]">
<SmiteChoice
slots={slots}
targetName={smiteTargetId}
targetName={targetName(smiteTargetId)}
onChoice={handleSmiteChoice}
onCancel={() => {
setSmiteTargetId(null)
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/components/game/targetLabels.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import type { TFunction } from "i18next"

interface Perceived {
id: string
description?: string
Expand All @@ -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<string, string> {
export function perceivedLabels(
entities: Perceived[],
t: (key: string, opts?: Record<string, unknown>) => string,
): Map<string, string> {
const base = entities.map((e) => e.description || e.name || t("game:unknown_creature"))
const total = new Map<string, number>()
for (const label of base) total.set(label, (total.get(label) ?? 0) + 1)
Expand Down
71 changes: 71 additions & 0 deletions src/dnd_simulator/content_loader/catalog_messages.py
Original file line number Diff line number Diff line change
@@ -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/<dir>/<id>.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}")
5 changes: 3 additions & 2 deletions src/dnd_simulator/core/awareness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/dnd_simulator/layers/entities/awareness_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -275,7 +275,7 @@ def build_combat_awareness(self, creature: Creature, query_fn: QueryFn | None =
from dnd_simulator.rules.modifiers import effective_ac, effective_speed

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] = []
Expand Down
8 changes: 2 additions & 6 deletions src/dnd_simulator/layers/entities/perception.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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"),
]
Expand Down
Loading
Loading