diff --git a/backend/docs/quest-system-design.md b/backend/docs/quest-system-design.md index 34e28c5..4e15997 100644 --- a/backend/docs/quest-system-design.md +++ b/backend/docs/quest-system-design.md @@ -31,6 +31,8 @@ Each town occupies a rectangular region roughly 15x15 tiles centered on the road ## 2. NPC Types +**Update (migration 000032+):** quest roles are `bounty_hunter` (offers `kill_count` + `collect_item`) and `elder` (`visit_town` + `collect_item`); gear sellers are `merchant`, `armorer`, `weapon`, `jeweler` (slot-filtered stock); `healer` unchanged. Legacy `quest_giver` / single `merchant` rows are migrated away. + Three NPC archetypes for MVP. Each NPC belongs to exactly one town. | Type | Role | Interaction | diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index 564872a..b1a260b 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -374,16 +374,6 @@ func (hm *HeroMovement) pickDestination(graph *RoadGraph) { } } - n := len(graph.TownOrder) - if n == 0 { - hm.DestinationTownID = 0 - return - } - if n == 1 { - hm.DestinationTownID = hm.CurrentTownID - return - } - idx := graph.TownOrderIndex(hm.CurrentTownID) if idx < 0 { if d := hm.firstOutgoingDestination(graph); d != 0 { @@ -2372,7 +2362,7 @@ func ProcessSingleHeroMovementTick( if sender != nil { hm.WanderingMerchantDeadline = now.Add(time.Duration(cfg.WanderingMerchantPromptTimeoutMs) * time.Millisecond) sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{ - NPCID: 0, NPCName: "Wandering Merchant", NPCNameKey: model.WanderingMerchantNPCKey, + NPCID: 0, NPCName: "Gillen Porter", NPCNameKey: model.WanderingMerchantNPCKey, Role: "alms", DialogueKey: model.WanderingMerchantDialogueKey, Cost: cost, }) } @@ -2492,7 +2482,7 @@ func ProcessSingleHeroMovementTick( hm.WanderingMerchantDeadline = now.Add(time.Duration(cfg.WanderingMerchantPromptTimeoutMs) * time.Millisecond) sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{ NPCID: 0, - NPCName: "Wandering Merchant", + NPCName: "Gillen Porter", NPCNameKey: model.WanderingMerchantNPCKey, Role: "wandering merchant", DialogueKey: model.WanderingMerchantDialogueKey, diff --git a/backend/internal/game/offline.go b/backend/internal/game/offline.go index cafd7fb..c2bfeb6 100644 --- a/backend/internal/game/offline.go +++ b/backend/internal/game/offline.go @@ -290,7 +290,7 @@ func (s *OfflineSimulator) applyOfflineTownTourNPCVisit(ctx context.Context, her cfg := tuning.Get() tryQuest := func() bool { - if npc.Type != "quest_giver" || s.questStore == nil { + if !model.IsQuestOfferNPCType(npc.Type) || s.questStore == nil { return false } hqs, err := s.questStore.ListHeroQuests(ctx, heroID) @@ -307,6 +307,7 @@ func (s *OfflineSimulator) applyOfflineTownTourNPCVisit(ctx context.Context, her s.logger.Warn("offline town tour: list quests by npc", "error", err) return false } + offered = model.FilterQuestTemplatesByNPCType(offered, npc.Type) for _, q := range offered { if _, ok := taken[q.ID]; ok { continue @@ -337,10 +338,11 @@ func (s *OfflineSimulator) applyOfflineTownTourNPCVisit(ctx context.Context, her return } - if npc.Type == "merchant" { + if model.IsGearVendorType(npc.Type) { gearCost := tuning.EffectiveTownMerchantGearCost(townLv) if s.gearStore != nil && gearCost > 0 && h.Gold >= gearCost { - items := RollTownMerchantStockItems(townLv, 1) + slots := model.GearVendorSlots(npc.Type) + items := RollTownMerchantStockItemsForSlots(townLv, 1, slots) if len(items) > 0 && TownMerchantRollIsUpgrade(h, items[0], now) { h.Gold -= gearCost drop, err := ApplyPreparedTownMerchantPurchase(ctx, s.gearStore, h, items[0], now) @@ -399,7 +401,7 @@ func (s *OfflineSimulator) applyOfflineTownTourNPCVisit(ctx context.Context, her } } - if npc.Type == "merchant" { + if model.IsGearVendorType(npc.Type) { share := cfg.MerchantTownAutoSellShare if share <= 0 || share > 1 { share = tuning.DefaultValues().MerchantTownAutoSellShare @@ -416,7 +418,7 @@ func (s *OfflineSimulator) applyOfflineTownTourNPCVisit(ctx context.Context, her return } - if npc.Type == "quest_giver" && al != nil { + if model.IsQuestOfferNPCType(npc.Type) && al != nil { al(heroID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseQuestGiverChecked, diff --git a/backend/internal/game/town_merchant_gear.go b/backend/internal/game/town_merchant_gear.go index f468bd4..3efe495 100644 --- a/backend/internal/game/town_merchant_gear.go +++ b/backend/internal/game/town_merchant_gear.go @@ -14,10 +14,17 @@ import ( // RollTownMerchantStockItems generates `count` gear rows for town-tier stock (one roll per slot order, unique slots first). func RollTownMerchantStockItems(refLevel int, count int) []*model.GearItem { + return RollTownMerchantStockItemsForSlots(refLevel, count, model.AllEquipmentSlots) +} + +// RollTownMerchantStockItemsForSlots rolls gear only from the given slots (e.g. per vendor type). +func RollTownMerchantStockItemsForSlots(refLevel int, count int, slots []model.EquipmentSlot) []*model.GearItem { if count < 1 { count = 1 } - slots := model.AllEquipmentSlots + if len(slots) == 0 { + slots = model.AllEquipmentSlots + } if count > len(slots) { count = len(slots) } diff --git a/backend/internal/game/town_tour.go b/backend/internal/game/town_tour.go index 6937109..fff4870 100644 --- a/backend/internal/game/town_tour.go +++ b/backend/internal/game/town_tour.go @@ -431,7 +431,7 @@ func processTownTourMovement( TownNameKey: townNameKey, WorldX: ex.TownTourStandX, WorldY: ex.TownTourStandY, }) - legacyMerchantSell := npc.Type == "merchant" + legacyMerchantSell := model.IsGearVendorType(npc.Type) if legacyMerchantSell { share := cfg.MerchantTownAutoSellShare if share <= 0 || share > 1 { diff --git a/backend/internal/handler/game.go b/backend/internal/handler/game.go index 366cb5a..221493b 100644 --- a/backend/internal/handler/game.go +++ b/backend/internal/handler/game.go @@ -465,7 +465,7 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, model.NPCEventResponse{ Type: "npc_event", NPC: model.NPCEventNPC{ - Name: "Wandering Merchant", + Name: "Gillen Porter", NameKey: model.WanderingMerchantNPCKey, Role: "alms", }, diff --git a/backend/internal/handler/npc.go b/backend/internal/handler/npc.go index 6a9e1ab..8f508e6 100644 --- a/backend/internal/handler/npc.go +++ b/backend/internal/handler/npc.go @@ -86,7 +86,8 @@ func dist2D(x1, y1, x2, y2 float64) float64 { } // loadHeroNPCInTown loads the hero, NPC row, town, and checks hero stand position is inside the town radius. -func (h *NPCHandler) loadHeroNPCInTown(ctx context.Context, telegramID, npcID int64, posX, posY float64, wantNPCType string) (*model.Hero, *model.NPC, *model.Town, error) { +// If allowedTypes is non-empty, npc.Type must match one of them. +func (h *NPCHandler) loadHeroNPCInTown(ctx context.Context, telegramID, npcID int64, posX, posY float64, allowedTypes ...string) (*model.Hero, *model.NPC, *model.Town, error) { if npcID == 0 { return nil, nil, nil, fmt.Errorf("npcId is required") } @@ -104,8 +105,17 @@ func (h *NPCHandler) loadHeroNPCInTown(ctx context.Context, telegramID, npcID in if npc == nil { return nil, nil, nil, fmt.Errorf("npc not found") } - if wantNPCType != "" && npc.Type != wantNPCType { - return nil, nil, nil, fmt.Errorf("npc type mismatch") + if len(allowedTypes) > 0 { + ok := false + for _, t := range allowedTypes { + if npc.Type == t { + ok = true + break + } + } + if !ok { + return nil, nil, nil, fmt.Errorf("npc type mismatch") + } } town, err := h.questStore.GetTown(ctx, npc.TownID) if err != nil { @@ -209,7 +219,7 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) { var actions []model.NPCInteractAction switch npc.Type { - case "quest_giver": + case model.NPCTypeBounty, model.NPCTypeElder, model.NPCTypeQuestGiver: refreshHours := tuning.EffectiveQuestOfferRefreshHours() if refreshHours <= 0 { refreshHours = 2 @@ -240,7 +250,7 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) { }) } - case "merchant": + case model.NPCTypeMerchant, model.NPCTypeArmorer, model.NPCTypeWeapon, model.NPCTypeJeweler: gearCost := tuning.EffectiveTownMerchantGearCost(game.TownEffectiveLevel(town)) actions = append(actions, model.NPCInteractAction{ ActionType: "shop_item", @@ -799,7 +809,8 @@ func (h *NPCHandler) BuyTownMerchantGear(w http.ResponseWriter, r *http.Request) return } - hero, npc, town, err := h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY, "merchant") + hero, npc, town, err := h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY, + model.NPCTypeMerchant, model.NPCTypeArmorer, model.NPCTypeWeapon, model.NPCTypeJeweler) if err != nil { msg := err.Error() switch msg { @@ -931,7 +942,8 @@ func (h *NPCHandler) MerchantStock(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "world engine unavailable"}) return } - hero, npc, town, err := h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY, "merchant") + hero, npc, town, err := h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY, + model.NPCTypeMerchant, model.NPCTypeArmorer, model.NPCTypeWeapon, model.NPCTypeJeweler) if err != nil { msg := err.Error() switch msg { @@ -949,7 +961,8 @@ func (h *NPCHandler) MerchantStock(w http.ResponseWriter, r *http.Request) { } townLv := game.TownEffectiveLevel(town) n := tuning.EffectiveMerchantTownStockCount() - items := game.RollTownMerchantStockItems(townLv, n) + slots := model.GearVendorSlots(npc.Type) + items := game.RollTownMerchantStockItemsForSlots(townLv, n, slots) costs := make([]int64, len(items)) for i, it := range items { if it == nil { diff --git a/backend/internal/handler/quest.go b/backend/internal/handler/quest.go index 6914da3..040a603 100644 --- a/backend/internal/handler/quest.go +++ b/backend/internal/handler/quest.go @@ -122,7 +122,7 @@ func (h *QuestHandler) ListBuildingsByTown(w http.ResponseWriter, r *http.Reques // ListQuestsByNPC returns quests offered by an NPC. // GET /api/v1/npcs/{npcId}/quests -// With ?telegramId= the list is filtered (no already-logged templates), level-scoped, capped, rotated on a configured cadence, and may be empty during a deterministic “dry spell” (see quest-system-design.md) — same rules as npc-interact. +// With ?telegramId= the list is filtered by NPC role (bounty_hunter vs elder), no already-logged templates, level-scoped, capped, rotated on a configured cadence, and may be empty during a deterministic “dry spell” (see quest-system-design.md) — same rules as npc-interact. // Without telegramId, returns all templates for that NPC (catalog / tools). func (h *QuestHandler) ListQuestsByNPC(w http.ResponseWriter, r *http.Request) { npcIDStr := chi.URLParam(r, "npcId") diff --git a/backend/internal/migrate/migrate.go b/backend/internal/migrate/migrate.go index 210829c..71f431e 100644 --- a/backend/internal/migrate/migrate.go +++ b/backend/internal/migrate/migrate.go @@ -41,9 +41,15 @@ func Run(ctx context.Context, pool *pgxpool.Pool, dir string) error { var files []string for _, e := range entries { - if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { - files = append(files, e.Name()) + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".sql") { + continue + } + // Fragments (e.g. generated snippets) must not run as migrations. + if strings.HasPrefix(name, "_") { + continue } + files = append(files, name) } sort.Strings(files) diff --git a/backend/internal/model/adventure_log_phrase_keys.go b/backend/internal/model/adventure_log_phrase_keys.go index a254c91..990414f 100644 --- a/backend/internal/model/adventure_log_phrase_keys.go +++ b/backend/internal/model/adventure_log_phrase_keys.go @@ -132,3 +132,13 @@ func TownVisitRandomPhraseKey(npcType string) string { } return "town_visit." + keyType + "." + slugs[rand.Intn(len(slugs))] } + +func init() { + qg := townVisitLineSlugs["quest_giver"] + merch := townVisitLineSlugs["merchant"] + townVisitLineSlugs["bounty_hunter"] = qg + townVisitLineSlugs["elder"] = qg + townVisitLineSlugs["armorer"] = merch + townVisitLineSlugs["weapon"] = merch + townVisitLineSlugs["jeweler"] = merch +} diff --git a/backend/internal/model/adventure_log_phrase_keys_test.go b/backend/internal/model/adventure_log_phrase_keys_test.go index e241e39..bf6db0f 100644 --- a/backend/internal/model/adventure_log_phrase_keys_test.go +++ b/backend/internal/model/adventure_log_phrase_keys_test.go @@ -32,7 +32,11 @@ func TestTownVisitPhraseKeyUsesSlugs(t *testing.T) { func TestTownVisitRandomPhraseKeyNonEmpty(t *testing.T) { for i := 0; i < 20; i++ { - k := TownVisitRandomPhraseKey("merchant") + k := TownVisitRandomPhraseKey("bounty_hunter") + if k == "" || len(k) < len("town_visit.bounty_hunter.") { + t.Fatalf("expected bounty_hunter phrase key, got %q", k) + } + k = TownVisitRandomPhraseKey("merchant") if k == "" || len(k) < len("town_visit.merchant.") { t.Fatalf("unexpected key %q", k) } diff --git a/backend/internal/model/building.go b/backend/internal/model/building.go index dfd5765..3ae2e0b 100644 --- a/backend/internal/model/building.go +++ b/backend/internal/model/building.go @@ -4,7 +4,7 @@ package model type TownBuilding struct { ID int64 `json:"id"` TownID int64 `json:"townId"` - BuildingType string `json:"buildingType"` // house.quest_giver, house.merchant, house.healer, decoration.* + BuildingType string `json:"buildingType"` // house.* façades, decoration.* OffsetX float64 `json:"offsetX"` OffsetY float64 `json:"offsetY"` Facing string `json:"facing"` // north, south, east, west diff --git a/backend/internal/model/quest.go b/backend/internal/model/quest.go index b4814ab..b086167 100644 --- a/backend/internal/model/quest.go +++ b/backend/internal/model/quest.go @@ -21,7 +21,7 @@ type NPC struct { TownID int64 `json:"townId"` Name string `json:"name"` NameKey string `json:"nameKey,omitempty"` - Type string `json:"type"` // quest_giver, merchant, healer + Type string `json:"type"` // merchant, armorer, weapon, jeweler, bounty_hunter, elder, healer OffsetX float64 `json:"offsetX"` OffsetY float64 `json:"offsetY"` BuildingID *int64 `json:"buildingId,omitempty"` diff --git a/backend/internal/storage/quest_store.go b/backend/internal/storage/quest_store.go index c55cde3..f93529f 100644 --- a/backend/internal/storage/quest_store.go +++ b/backend/internal/storage/quest_store.go @@ -264,10 +264,18 @@ func (s *QuestStore) HeroTakenQuestTemplateIDs(ctx context.Context, heroID int64 // ListOfferableQuestsForNPC returns level-matching NPC quests excluding templates already on the hero's log. // limit comes from tuning (e.g. questOffersPerNPC). timeBucket is a stable bucket (e.g. unixSeconds/7200) for rotations. func (s *QuestStore) ListOfferableQuestsForNPC(ctx context.Context, heroID, npcID int64, heroLevel int, limit int, timeBucket int64) ([]model.Quest, error) { + npcRow, err := s.GetNPCByID(ctx, npcID) + if err != nil { + return nil, fmt.Errorf("load npc for quest offers: %w", err) + } + if npcRow == nil { + return nil, fmt.Errorf("npc not found") + } all, err := s.ListQuestsByNPCForHeroLevel(ctx, npcID, heroLevel) if err != nil { return nil, err } + all = model.FilterQuestTemplatesByNPCType(all, npcRow.Type) takenIDs, err := s.HeroTakenQuestTemplateIDs(ctx, heroID) if err != nil { return nil, err diff --git a/docs/specification-content-catalog.md b/docs/specification-content-catalog.md index 6b4dbb2..b27af18 100644 --- a/docs/specification-content-catalog.md +++ b/docs/specification-content-catalog.md @@ -129,6 +129,21 @@ Naming convention: | `npc.hermit.ash_sage.v1` | Ash Sage | flavor_talk | `npc.model.ash_sage.v1` | `event.social.pass.v1` | | `npc.child.lost_acorn.v1` | Lost Acorn Kid | flavor_talk | `npc.model.lost_acorn.v1` | `event.social.pass.v1` | +### 0c.1) Town building façades (`town_buildings.building_type`) + +Stable keys for server-driven settlement props (non-interactive shell; NPC role is on `npcs.type`). + +| buildingType | notes | +|---|---| +| `house.merchant` | Soft-goods / travel vendor stall | +| `house.armorer` | Armor pieces (legs, wrist, chest, head slots) | +| `house.weapon_smith` | Weapons (`main_hand`) | +| `house.jeweler` | Rings, amulets (`finger`, `neck`) | +| `house.bounty_hunter` | Contracts: kill / collect quests | +| `house.elder` | Civic / travel: visit / collect quests | +| `house.healer` | Healing services | +| `house.quest_giver` | Legacy façade (pre-migration rows may retain until reauthored) | + ## 1) Monster Model Catalog Naming convention: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a548291..d6e3bf1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1618,7 +1618,10 @@ export function App() { potionCost={npcShopCosts.potionCost} healCost={npcShopCosts.healCost} getHeroWorldPosition={() => engineRef.current?.getHeroDisplayWorldPosition() ?? { x: 0, y: 0 }} - onClose={() => setSelectedNPC(null)} + onClose={() => { + setNpcInteractionDismissed(dialogNpc.id); + setSelectedNPC(null); + }} onQuestsChanged={refreshHeroQuests} onHeroUpdated={handleNPCHeroUpdated} onToast={(message, color) => setToast({ message, color })} diff --git a/frontend/src/game/renderer.ts b/frontend/src/game/renderer.ts index 9e46a18..d7414d2 100644 --- a/frontend/src/game/renderer.ts +++ b/frontend/src/game/renderer.ts @@ -945,6 +945,22 @@ export class GameRenderer { this._drawHouse(gfx, bx, by, w * 1.1, h, rh * 0.8, 0x44aa55, 0x2a5a30, 1); this._drawTownStall(gfx, bx + w * 0.7, by + 4, scale * 0.6); this._drawBuildingIcon(gfx, bx, by - h - rh * 0.3, '$', 0x88dd88, scale); + } else if (bt === 'house.armorer') { + this._drawHouse(gfx, bx, by, w * 1.05, h, rh * 0.85, 0x5a6e8a, 0x2a3548, 1); + this._drawBuildingIcon(gfx, bx, by - h - rh * 0.4, 'A', 0xaaccff, scale); + } else if (bt === 'house.weapon_smith') { + this._drawHouse(gfx, bx, by, w * 1.05, h, rh * 0.85, 0x8a5a3a, 0x4a3020, 1); + this._drawBuildingIcon(gfx, bx, by - h - rh * 0.35, 'W', 0xffaa66, scale); + } else if (bt === 'house.jeweler') { + this._drawHouse(gfx, bx, by, w, h * 0.95, rh * 0.9, 0x7a4a9a, 0x3a2050, 2); + this._drawBuildingIcon(gfx, bx, by - h - rh * 0.45, 'J', 0xdd88ff, scale); + } else if (bt === 'house.bounty_hunter') { + this._drawHouse(gfx, bx, by, w, h, rh, 0x906040, 0x4a2818, 0); + this._drawFence(gfx, bx, by, w, 'right'); + this._drawBuildingIcon(gfx, bx, by - h - rh * 0.5, 'B', 0xffcc44, scale); + } else if (bt === 'house.elder') { + this._drawHouse(gfx, bx, by, w * 0.98, h, rh * 1.05, 0x9a8860, 0x5a4830, 0); + this._drawBuildingIcon(gfx, bx, by - h - rh * 0.5, 'E', 0xeeddaa, scale); } else if (bt === 'house.healer') { this._drawHouse(gfx, bx, by, w, h, rh, 0xccccdd, 0x5555aa, 2); this._drawBuildingIcon(gfx, bx, by - h - rh * 0.5, '+', 0xff6666, scale); @@ -1290,17 +1306,42 @@ export class GameRenderer { switch (npc.type) { case 'quest_giver': + case 'bounty_hunter': bodyColor = 0xdaa520; bodyStroke = 0x8a6510; iconText = '!'; iconColor = 0xffd700; break; + case 'elder': + bodyColor = 0xc4a574; + bodyStroke = 0x7a6040; + iconText = '\u2020'; + iconColor = 0xeeddaa; + break; case 'merchant': bodyColor = 0x44aa55; bodyStroke = 0x2a7a3a; iconText = '$'; iconColor = 0x88dd88; break; + case 'armorer': + bodyColor = 0x5a7a9a; + bodyStroke = 0x304560; + iconText = '\u25C9'; + iconColor = 0xaaccff; + break; + case 'weapon': + bodyColor = 0xaa6633; + bodyStroke = 0x6a3818; + iconText = '\u2694'; + iconColor = 0xffaa66; + break; + case 'jeweler': + bodyColor = 0x8844aa; + bodyStroke = 0x502060; + iconText = '\u2666'; + iconColor = 0xdd88ff; + break; case 'healer': bodyColor = 0xdddddd; bodyStroke = 0x8888aa; diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts index e8c101c..19a438a 100644 --- a/frontend/src/game/types.ts +++ b/frontend/src/game/types.ts @@ -297,7 +297,15 @@ export interface NPC { townId: number; name: string; nameKey?: string; - type: 'quest_giver' | 'merchant' | 'healer'; + type: + | 'quest_giver' + | 'merchant' + | 'armorer' + | 'weapon' + | 'jeweler' + | 'bounty_hunter' + | 'elder' + | 'healer'; offsetX: number; offsetY: number; buildingId?: number; @@ -382,7 +390,15 @@ export interface NPCData { id: number; name: string; nameKey?: string; - type: 'quest_giver' | 'merchant' | 'healer'; + type: + | 'quest_giver' + | 'merchant' + | 'armorer' + | 'weapon' + | 'jeweler' + | 'bounty_hunter' + | 'elder' + | 'healer'; worldX: number; worldY: number; buildingId?: number; diff --git a/frontend/src/i18n/contentLabels.ts b/frontend/src/i18n/contentLabels.ts index 9c3f273..99edebe 100644 --- a/frontend/src/i18n/contentLabels.ts +++ b/frontend/src/i18n/contentLabels.ts @@ -1,5 +1,6 @@ import type { Locale } from './localeCodes'; import { enemyTypeLabel } from './loadLocales'; +import { npcByIdKeyLabel } from './npcGeneratedNames'; /** Stable keys aligned with backend migrations / model constants. */ export const WANDERING_MERCHANT_NPC_KEY = 'npc.wandering_merchant.v1'; @@ -40,6 +41,7 @@ export const TOWN_ID_TO_NAME_KEY: Record = { 29: 'town.sungrasp.v1', 30: 'town.glimmerford.v1', 31: 'town.starveil.v1', + 32: 'town.capital.v1', }; /** Localized town label from numeric `towns.id` (visit_town quest target, etc.). */ @@ -86,100 +88,129 @@ const TOWNS: Record = { 'town.sungrasp.v1': { en: 'Sungrasp', ru: 'Санграсп' }, 'town.glimmerford.v1': { en: 'Glimmerford', ru: 'Глиммерфорд' }, 'town.starveil.v1': { en: 'Starveil', ru: 'Старвейл' }, + 'town.capital.v1': { en: 'Capital', ru: 'Столица' }, }; const NPCS: Record = { - 'npc.elder_maren.v1': { en: 'Elder Maren', ru: 'Старейшина Марен' }, - 'npc.peddler_finn.v1': { en: 'Peddler Finn', ru: 'Бродячий торговец Финн' }, - 'npc.sister_asha.v1': { en: 'Sister Asha', ru: 'Сестра Аша' }, - 'npc.guard_halric.v1': { en: 'Guard Halric', ru: 'Страж Халрик' }, - 'npc.trader_wynn.v1': { en: 'Trader Wynn', ru: 'Торговец Винн' }, - 'npc.scholar_orin.v1': { en: 'Scholar Orin', ru: 'Учёный Орин' }, - 'npc.bone_merchant.v1': { en: 'Bone Merchant', ru: 'Торговец костями' }, - 'npc.priestess_liora.v1': { en: 'Priestess Liora', ru: 'Жрица Лиора' }, - [WANDERING_MERCHANT_NPC_KEY]: { en: 'Wandering Merchant', ru: 'Бродячий торговец' }, - 'npc.clerk_sera.v1': { en: 'Clerk Sera', ru: 'Клер Сера' }, - 'npc.notary_bram.v1': { en: 'Notary Bram', ru: 'Нотариус Брам' }, - 'npc.copper_nils.v1': { en: 'Copper Nils', ru: 'Коппер Нилс' }, - 'npc.tin_mara.v1': { en: 'Tin Mara', ru: 'Тин Мара' }, - 'npc.sister_calm.v1': { en: 'Sister Calm', ru: 'Сестра Калм' }, - 'npc.foreman_rook.v1': { en: 'Foreman Rook', ru: 'Форман Рук' }, - 'npc.wire_merchant.v1': { en: 'Wire Merchant', ru: 'Торговец проволокой' }, - 'npc.bolt_jada.v1': { en: 'Bolt Jada', ru: 'Болт Джада' }, - 'npc.sage_mottle.v1': { en: 'Sage Mottle', ru: 'Сейдж Моттл' }, - 'npc.warden_pike.v1': { en: 'Warden Pike', ru: 'Варден Пайк' }, - 'npc.ash_vendor.v1': { en: 'Ash Vendor', ru: 'Торговец золой' }, - 'npc.scrap_yori.v1': { en: 'Scrap Yori', ru: 'Скрап Йори' }, - 'npc.herb_rill.v1': { en: 'Herb Rill', ru: 'Херб Рилл' }, - 'npc.miller_tove.v1': { en: 'Miller Tove', ru: 'Миллер Тов' }, - 'npc.grain_peddler.v1': { en: 'Grain Peddler', ru: 'Зерновой бродяга' }, - 'npc.sack_ren.v1': { en: 'Sack Ren', ru: 'Сак Рен' }, - 'npc.brother_salve.v1': { en: 'Brother Salve', ru: 'Брат Сальв' }, - 'npc.stone_judge.v1': { en: 'Stone Judge', ru: 'Каменный судья' }, - 'npc.edge_trader.v1': { en: 'Edge Trader', ru: 'Торговец с краю' }, - 'npc.crack_merchant.v1': { en: 'Crack Merchant', ru: 'Торговец из трещины' }, - 'npc.sister_flint.v1': { en: 'Sister Flint', ru: 'Сестра Флинт' }, - 'npc.starward_oren.v1': { en: 'Starward Oren', ru: 'Старворд Орен' }, - 'npc.spire_imports.v1': { en: 'Spire Imports', ru: 'Спайр Импортс' }, - 'npc.comet_outfitter.v1': { en: 'Comet Outfitter', ru: 'Комет Аутфиттер' }, - 'npc.void_medic.v1': { en: 'Void Medic', ru: 'Медик пустоты' }, - 'npc.brine_archivist.v1': { en: 'Brine Archivist', ru: 'Архивариус рассола' }, - 'npc.salt_broker.v1': { en: 'Salt Broker', ru: 'Солёный брокер' }, - 'npc.reed_trader.v1': { en: 'Reed Trader', ru: 'Торговец тростником' }, - 'npc.mud_healer.v1': { en: 'Mud Healer', ru: 'Грязевой лекарь' }, - 'npc.post_warden.v1': { en: 'Post Warden', ru: 'Страж поста' }, - 'npc.ironmonger.v1': { en: 'Ironmonger', ru: 'Железный торговец' }, - 'npc.rivet_seller.v1': { en: 'Rivet Seller', ru: 'Продавец заклёпок' }, - 'npc.forge_medic.v1': { en: 'Forge Medic', ru: 'Кузнечный медик' }, - 'npc.bog_chronicler.v1': { en: 'Bog Chronicler', ru: 'Хронист болота' }, - 'npc.fen_notary.v1': { en: 'Fen Notary', ru: 'Нотариус топи' }, - 'npc.mire_merchant.v1': { en: 'Mire Merchant', ru: 'Торговец трясиной' }, - 'npc.reed_coin.v1': { en: 'Reed Coin', ru: 'Рид Коин' }, - 'npc.swamp_mender.v1': { en: 'Swamp Mender', ru: 'Болотный латальщик' }, - 'npc.dune_scout.v1': { en: 'Dune Scout', ru: 'Разведчик дюн' }, - 'npc.silt_trader.v1': { en: 'Silt Trader', ru: 'Торговец илом' }, - 'npc.sand_peddler.v1': { en: 'Sand Peddler', ru: 'Песочный бродяга' }, - 'npc.grit_healer.v1': { en: 'Grit Healer', ru: 'Грит‑лекарь' }, - 'npc.barrow_keeper.v1': { en: 'Barrow Keeper', ru: 'Хранитель курганов' }, - 'npc.bone_outfitter.v1': { en: 'Bone Outfitter', ru: 'Костяной снаряженец' }, - 'npc.cold_peddler.v1': { en: 'Cold Peddler', ru: 'Холодный бродяга' }, - 'npc.shroud_medic.v1': { en: 'Shroud Medic', ru: 'Медик покрова' }, - 'npc.mist_ranger.v1': { en: 'Mist Ranger', ru: 'Рейнджер тумана' }, - 'npc.fog_trader.v1': { en: 'Fog Trader', ru: 'Торговец туманом' }, - 'npc.dew_merchant.v1': { en: 'Dew Merchant', ru: 'Торговец росой' }, - 'npc.vapor_healer.v1': { en: 'Vapor Healer', ru: 'Лекарь пара' }, - 'npc.hollow_scribe.v1': { en: 'Hollow Scribe', ru: 'Писарь пустоты' }, - 'npc.mer_imports.v1': { en: 'Mer Imports', ru: 'Мер Импортс' }, - 'npc.rot_trader.v1': { en: 'Rot Trader', ru: 'Торговец гнилью' }, - 'npc.bog_medic.v1': { en: 'Bog Medic', ru: 'Болотный медик' }, - 'npc.herald_ash.v1': { en: 'Ash Herald', ru: 'Глашатай пепла' }, - 'npc.cinder_seller.v1': { en: 'Cinder Seller', ru: 'Продавец золы' }, - 'npc.ember_peddler.v1': { en: 'Ember Peddler', ru: 'Угольный бродяга' }, - 'npc.ash_healer.v1': { en: 'Ash Healer', ru: 'Пепельный лекарь' }, - 'npc.thorn_watcher.v1': { en: 'Thorn Watcher', ru: 'Дозорный шипов' }, - 'npc.briar_trader.v1': { en: 'Briar Trader', ru: 'Торговец шипами' }, - 'npc.root_seller.v1': { en: 'Root Seller', ru: 'Продавец корней' }, - 'npc.leaf_medic.v1': { en: 'Leaf Medic', ru: 'Лиственный медик' }, - 'npc.gale_factor.v1': { en: 'Gale Factor', ru: 'Фактор шторма' }, - 'npc.wind_outfitter.v1': { en: 'Wind Outfitter', ru: 'Ветряной снаряженец' }, - 'npc.gust_peddler.v1': { en: 'Gust Peddler', ru: 'Порывистый бродяга' }, - 'npc.breeze_healer.v1': { en: 'Breeze Healer', ru: 'Лекарь бриза' }, - 'npc.frost_archivist.v1': { en: 'Frost Archivist', ru: 'Морозный архивариус' }, - 'npc.rime_trader.v1': { en: 'Rime Trader', ru: 'Торговец инеем' }, - 'npc.hoarfrost_seller.v1': { en: 'Hoarfrost Seller', ru: 'Продавец инея' }, - 'npc.ice_medic.v1': { en: 'Ice Medic', ru: 'Лёд‑медик' }, - 'npc.sun_warden.v1': { en: 'Sun Warden', ru: 'Страж солнца' }, - 'npc.cliff_merchant.v1': { en: 'Cliff Merchant', ru: 'Утёсный торговец' }, - 'npc.crag_peddler.v1': { en: 'Crag Peddler', ru: 'Краг‑бродяга' }, - 'npc.dust_healer.v1': { en: 'Dust Healer', ru: 'Пыльный лекарь' }, - 'npc.ford_marshal.v1': { en: 'Ford Marshal', ru: 'Маршал брода' }, - 'npc.river_trader.v1': { en: 'River Trader', ru: 'Речной торговец' }, - 'npc.bridge_seller.v1': { en: 'Bridge Seller', ru: 'Продавец мостов' }, - 'npc.stream_medic.v1': { en: 'Stream Medic', ru: 'Ручьевой медик' }, - 'npc.veil_seer.v1': { en: 'Veil Seer', ru: 'Видящая завесы' }, - 'npc.star_trader.v1': { en: 'Star Trader', ru: 'Звёздный торговец' }, - 'npc.nebula_peddler.v1': { en: 'Nebula Peddler', ru: 'Туманность‑бродяга' }, - 'npc.veil_mender_starveil.v1': { en: 'Veil Mender', ru: 'Латальщик завесы' }, + 'npc.elder_maren.v1': { en: 'Maren Thistlewood', ru: 'Марен Тистлвуд' }, + 'npc.peddler_finn.v1': { en: 'Finn Marlow', ru: 'Финн Марлоу' }, + 'npc.sister_asha.v1': { en: 'Asha Kentwell', ru: 'Аша Кентвелл' }, + 'npc.guard_halric.v1': { en: 'Halric Morrow', ru: 'Халрик Морроу' }, + 'npc.trader_wynn.v1': { en: 'Wynn Cartwright', ru: 'Винн Картрайт' }, + 'npc.scholar_orin.v1': { en: 'Orin Aldgate', ru: 'Орин Олдгейт' }, + 'npc.bone_merchant.v1': { en: 'Osbert Waynwood', ru: 'Осберт Вейнвуд' }, + 'npc.priestess_liora.v1': { en: 'Liora Selwyn', ru: 'Лиора Селвин' }, + 'npc.brandric_thacker.v1': { en: 'Brandric Thacker', ru: 'Брандрик Тэкер' }, + 'npc.conrad_pitwright.v1': { en: 'Conrad Pitwright', ru: 'Конрад Питрайт' }, + 'npc.nessa_bramble.v1': { en: 'Nessa Bramble', ru: 'Несса Брамбл' }, + 'npc.torin_marshwick.v1': { en: 'Torin Marshwick', ru: 'Торин Маршвик' }, + 'npc.renulf_broadmere.v1': { en: 'Renulf Broadmere', ru: 'Ренульф Бродмир' }, + 'npc.kael_ironwright.v1': { en: 'Kael Ironwright', ru: 'Кейл Айронрайт' }, + 'npc.edmund_cinderwell.v1': { en: 'Edmund Cinderwell', ru: 'Эдмунд Синдервелл' }, + 'npc.aelith_northgate.v1': { en: 'Aelith Northgate', ru: 'Аэлит Нортгейт' }, + 'npc.dorian_hawke.v1': { en: 'Dorian Hawke', ru: 'Дориан Хоук' }, + 'npc.mariel_starling.v1': { en: 'Mariel Starling', ru: 'Мариэль Старлинг' }, + 'npc.milo_ropewalk.v1': { en: 'Milo Ropewalk', ru: 'Мило Роупуок' }, + 'npc.lissa_harcourt.v1': { en: 'Lissa Harcourt', ru: 'Лисса Харкорт' }, + 'npc.jasper_kindling.v1': { en: 'Jasper Kindling', ru: 'Джаспер Киндлинг' }, + 'npc.kess_wiley.v1': { en: 'Kess Wiley', ru: 'Кесс Уайли' }, + 'npc.aldwin_relicton.v1': { en: 'Aldwin Relicton', ru: 'Олдвин Реликтон' }, + 'npc.torvik_grimstad.v1': { en: 'Torvik Grimstad', ru: 'Торвик Гримстад' }, + 'npc.morna_fenwick.v1': { en: 'Morna Fenwick', ru: 'Морна Фенвик' }, + 'npc.morah_ellis.v1': { en: 'Morah Ellis', ru: 'Мора Эллис' }, + [WANDERING_MERCHANT_NPC_KEY]: { en: 'Gillen Porter', ru: 'Гиллен Портер' }, + 'npc.clerk_sera.v1': { en: 'Sera Whitcomb', ru: 'Сера Уиткомб' }, + 'npc.notary_bram.v1': { en: 'Bram Ashcombe', ru: 'Брам Эшкомб' }, + 'npc.copper_nils.v1': { en: 'Nils Copperton', ru: 'Нилс Коппертон' }, + 'npc.tin_mara.v1': { en: 'Mara Tinwell', ru: 'Мара Тинвелл' }, + 'npc.sister_calm.v1': { en: 'Agnes Stillwater', ru: 'Агнес Стилуотер' }, + 'npc.foreman_rook.v1': { en: 'Rodrick Cantrell', ru: 'Родрик Кантрелл' }, + 'npc.wire_merchant.v1': { en: 'Wulfric Strand', ru: 'Вульфрик Стрэнд' }, + 'npc.bolt_jada.v1': { en: 'Jada Boltwright', ru: 'Джада Болтрайт' }, + 'npc.sage_mottle.v1': { en: 'Alaric Motlow', ru: 'Аларик Мотлоу' }, + 'npc.warden_pike.v1': { en: 'Percival Pike', ru: 'Персиваль Пайк' }, + 'npc.ash_vendor.v1': { en: 'Eadric Ashenford', ru: 'Эадрик Эшенфорд' }, + 'npc.scrap_yori.v1': { en: 'Yoric Scarn', ru: 'Йорик Скарн' }, + 'npc.herb_rill.v1': { en: 'Rillian Hereward', ru: 'Риллиан Херуорд' }, + 'npc.miller_tove.v1': { en: 'Tove Millerson', ru: 'Тове Миллерсон' }, + 'npc.grain_peddler.v1': { en: 'Gareth Grantham', ru: 'Гарет Грантам' }, + 'npc.sack_ren.v1': { en: 'Renulf Sackville', ru: 'Ренульф Саквилл' }, + 'npc.brother_salve.v1': { en: 'Bernard Lukin', ru: 'Бернард Лукин' }, + 'npc.stone_judge.v1': { en: 'Aldwin Grimston', ru: 'Олдвим Гримстон' }, + 'npc.edge_trader.v1': { en: 'Edmund Edgerton', ru: 'Эдмунд Эджертон' }, + 'npc.crack_merchant.v1': { en: 'Crispin Aylesford', ru: 'Криспин Эйлсфорд' }, + 'npc.sister_flint.v1': { en: 'Brunhild Flint', ru: 'Брунхильд Флинт' }, + 'npc.starward_oren.v1': { en: 'Oren Starward', ru: 'Орен Старворд' }, + 'npc.spire_imports.v1': { en: 'Simon Spirewell', ru: 'Саймон Спайрвелл' }, + 'npc.comet_outfitter.v1': { en: 'Hugh Comstock', ru: 'Хью Комсток' }, + 'npc.void_medic.v1': { en: 'Yves Portier', ru: 'Ив Портье' }, + 'npc.brine_archivist.v1': { en: 'Cedric Brinewell', ru: 'Седрик Брайнвелл' }, + 'npc.salt_broker.v1': { en: 'Osmund Salter', ru: 'Осмунд Солтер' }, + 'npc.reed_trader.v1': { en: 'Rhys Reedman', ru: 'Рис Ридман' }, + 'npc.mud_healer.v1': { en: 'Godfrey Middleton', ru: 'Годфри Миддлтон' }, + 'npc.post_warden.v1': { en: 'Wystan Postlethwaite', ru: 'Вистан Постлтвейт' }, + 'npc.ironmonger.v1': { en: 'Ivo Ironside', ru: 'Иво Айронсайд' }, + 'npc.rivet_seller.v1': { en: 'Roland Rivett', ru: 'Роланд Риветт' }, + 'npc.forge_medic.v1': { en: 'Lucan Forrest', ru: 'Люкан Форрест' }, + 'npc.bog_chronicler.v1': { en: 'Alaric Boghurst', ru: 'Аларик Богхерст' }, + 'npc.fen_notary.v1': { en: 'Norbert Fenwick', ru: 'Норберт Фенвик' }, + 'npc.mire_merchant.v1': { en: 'Miles Myreham', ru: 'Майлз Майрэм' }, + 'npc.reed_coin.v1': { en: 'Cuthbert Reed', ru: 'Кутберт Рид' }, + 'npc.swamp_mender.v1': { en: 'Wendel Marsham', ru: 'Вендел Маршам' }, + 'npc.dune_scout.v1': { en: 'Sigurd Dunstan', ru: 'Сигурд Дунстан' }, + 'npc.silt_trader.v1': { en: 'Silas Siltwell', ru: 'Сайлас Силтвелл' }, + 'npc.sand_peddler.v1': { en: 'Peter Sanderson', ru: 'Питер Сандерсон' }, + 'npc.grit_healer.v1': { en: 'Griselda Holt', ru: 'Грисельда Холт' }, + 'npc.barrow_keeper.v1': { en: 'Bartholomew Howe', ru: 'Бартоломью Хоу' }, + 'npc.bone_outfitter.v1': { en: 'Baldwin Bonewright', ru: 'Болдуин Бонрайт' }, + 'npc.cold_peddler.v1': { en: 'Cole Aldridge', ru: 'Кол Олдридж' }, + 'npc.shroud_medic.v1': { en: 'Shadrach Morrow', ru: 'Шадрак Морроу' }, + 'npc.mist_ranger.v1': { en: 'Rowan Mistwell', ru: 'Роуан Миствелл' }, + 'npc.fog_trader.v1': { en: 'Fergus Fogarty', ru: 'Фергус Фогарти' }, + 'npc.dew_merchant.v1': { en: 'Dewi Tarrant', ru: 'Дьюи Таррант' }, + 'npc.vapor_healer.v1': { en: 'Vespasian Vale', ru: 'Веспасиан Вейл' }, + 'npc.hollow_scribe.v1': { en: 'Hugo Holloway', ru: 'Хьюго Холлоуэй' }, + 'npc.mer_imports.v1': { en: 'Meredith Stowe', ru: 'Мередит Стоу' }, + 'npc.rot_trader.v1': { en: 'Roderick Rotherham', ru: 'Родрик Ротерем' }, + 'npc.bog_medic.v1': { en: 'Beatrice Boghurst', ru: 'Беатрис Богхерст' }, + 'npc.herald_ash.v1': { en: 'Ashford Hale', ru: 'Эшфорд Хейл' }, + 'npc.cinder_seller.v1': { en: 'Cyril Cinders', ru: 'Сирил Синдерс' }, + 'npc.ember_peddler.v1': { en: 'Emrys Emberly', ru: 'Эмрис Эмберли' }, + 'npc.ash_healer.v1': { en: 'Alicia Ashford', ru: 'Алисия Эшфорд' }, + 'npc.thorn_watcher.v1': { en: 'Thorne Hawthorn', ru: 'Торн Хоторн' }, + 'npc.briar_trader.v1': { en: 'Brian Briarton', ru: 'Брайан Брайартон' }, + 'npc.root_seller.v1': { en: 'Rowan Rootwell', ru: 'Роуан Рутвелл' }, + 'npc.leaf_medic.v1': { en: 'Leofric Leaford', ru: 'Леофрик Лиффорд' }, + 'npc.gale_factor.v1': { en: 'Galfrid Gales', ru: 'Галфрид Гейлс' }, + 'npc.wind_outfitter.v1': { en: 'Wynstan Windham', ru: 'Винстан Виндхэм' }, + 'npc.gust_peddler.v1': { en: 'Gustav Merseburg', ru: 'Густав Мерсебург' }, + 'npc.breeze_healer.v1': { en: 'Blaise Brissot', ru: 'Блез Бриссо' }, + 'npc.frost_archivist.v1': { en: 'Archibald Frostwick', ru: 'Арчибальд Фроствик' }, + 'npc.rime_trader.v1': { en: 'Rhys Rimer', ru: 'Рис Ример' }, + 'npc.hoarfrost_seller.v1': { en: 'Horace Hoarwell', ru: 'Хорас Хоаруэлл' }, + 'npc.ice_medic.v1': { en: 'Isolde Ismay', ru: 'Изольда Измей' }, + 'npc.sun_warden.v1': { en: 'Solomon Sunderland', ru: 'Соломон Сандерленд' }, + 'npc.cliff_merchant.v1': { en: 'Clifford Cliffeton', ru: 'Клиффорд Клиффетон' }, + 'npc.crag_peddler.v1': { en: 'Craig Cragwell', ru: 'Крейг Крагвелл' }, + 'npc.dust_healer.v1': { en: 'Dustin Harwell', ru: 'Дастин Харуэлл' }, + 'npc.ford_marshal.v1': { en: 'Marshall Fordham', ru: 'Маршалл Фордхэм' }, + 'npc.river_trader.v1': { en: 'Rivers Trent', ru: 'Риверс Трент' }, + 'npc.bridge_seller.v1': { en: 'Bridges Ballard', ru: 'Бриджес Баллард' }, + 'npc.stream_medic.v1': { en: 'Sterling Brook', ru: 'Стерлинг Брук' }, + 'npc.veil_seer.v1': { en: 'Sevrin Veilcourt', ru: 'Севрин Вейлкорт' }, + 'npc.star_trader.v1': { en: 'Sterling Starwell', ru: 'Стерлинг Старвелл' }, + 'npc.nebula_peddler.v1': { en: 'Neville Nevett', ru: 'Невилл Неветт' }, + 'npc.veil_mender_starveil.v1': { en: 'Vera Veilhart', ru: 'Вера Вейлхарт' }, + 'npc.capital.merchant_clerk.v1': { en: 'Hugh Pennington', ru: 'Хью Пеннингтон' }, + 'npc.capital.armorer.v1': { en: "Raoul d'Aubigny", ru: 'Рауль д’Обиньи' }, + 'npc.capital.smith.v1': { en: 'Gilles Ferron', ru: 'Жиль Феррон' }, + 'npc.capital.jeweler.v1': { en: 'Ysabel Tremaine', ru: 'Изабель Тремейн' }, + 'npc.capital.bounty_agent_a.v1': { en: 'Roderick Vaughn', ru: 'Родрик Вон' }, + 'npc.capital.bounty_agent_b.v1': { en: 'Matteo Fabbri', ru: 'Маттео Фаббри' }, + 'npc.capital.elder.v1': { en: 'Anselm Corwyn', ru: 'Ансельм Корвин' }, + 'npc.capital.healer.v1': { en: 'Clothilde Mercier', ru: 'Клотильда Мерсье' }, + 'npc.capital.second_armorer.v1': { en: 'Bertrand Hale', ru: 'Бертранд Хейл' }, + 'npc.capital.second_jeweler.v1': { en: 'Eleonore Rivard', ru: 'Элеонор Ривар' }, }; const DIALOGUES: Record = { @@ -201,6 +232,8 @@ export function townLabel(locale: Locale, key: string | undefined, fallback: str export function npcLabel(locale: Locale, key: string | undefined, fallback: string): string { if (!key) return fallback; + const byId = npcByIdKeyLabel(locale, key); + if (byId) return byId; const b = NPCS[key]; return b ? pick(locale, b) : fallback; } diff --git a/frontend/src/i18n/en.yml b/frontend/src/i18n/en.yml index 420fa5d..3269f79 100644 --- a/frontend/src/i18n/en.yml +++ b/frontend/src/i18n/en.yml @@ -93,7 +93,12 @@ ui: failedToAbandonQuest: Failed to abandon quest completed: Completed questGiver: Quest Giver + bountyHunter: Bounty Hunter + elder: Elder merchant: Merchant + armorer: Armorer + weaponSmith: Smith + jeweler: Jeweler healer: Healer npc: NPC buyPotion: Buy Potion @@ -598,6 +603,41 @@ town_npc_visit: stamp_ink_thumb: Ink stains their thumb like a second seal. reward_bag_heavier: The reward bag looks heavier than your conscience. last_hero_failed_joke: They joke that the last hero failed upward. Ha. + bounty_hunter: + scrolls_wax_desk: Scrolls and wax seals clutter the quest giver’s desk. + ink_stained_map_tap: They tap a map with an ink-stained finger. + busy_roads_noncommittal: “Busy roads,” they say — you agree, noncommittally. + draft_parchment_smell: A draft carries the smell of old parchment. + squint_spine_legend: They squint as if measuring your spine against a legend. + promise_listen_worth_it: You promise to listen; they promise it will be worth it. + elder: + scrolls_wax_desk: Scrolls and wax seals clutter the quest giver’s desk. + ink_stained_map_tap: They tap a map with an ink-stained finger. + busy_roads_noncommittal: “Busy roads,” they say — you agree, noncommittally. + draft_parchment_smell: A draft carries the smell of old parchment. + squint_spine_legend: They squint as if measuring your spine against a legend. + promise_listen_worth_it: You promise to listen; they promise it will be worth it. + armorer: + crates_in_shade: You glance over crates and bundles stacked in the shade. + practiced_tired_smile: The merchant greets you with a practiced, tired smile. + chalk_prices_twice: Chalk prices are crossed out twice — the road tax of optimism. + rumors_bandits_carts: You swap rumors about bandits and broken cart wheels. + bell_traveler_pack: A bell tinkles as another traveler shoulders their pack. + step_back_tally_gold: You step back, mentally tallying what you can afford. + weapon: + crates_in_shade: You glance over crates and bundles stacked in the shade. + practiced_tired_smile: The merchant greets you with a practiced, tired smile. + chalk_prices_twice: Chalk prices are crossed out twice — the road tax of optimism. + rumors_bandits_carts: You swap rumors about bandits and broken cart wheels. + bell_traveler_pack: A bell tinkles as another traveler shoulders their pack. + step_back_tally_gold: You step back, mentally tallying what you can afford. + jeweler: + crates_in_shade: You glance over crates and bundles stacked in the shade. + practiced_tired_smile: The merchant greets you with a practiced, tired smile. + chalk_prices_twice: Chalk prices are crossed out twice — the road tax of optimism. + rumors_bandits_carts: You swap rumors about bandits and broken cart wheels. + bell_traveler_pack: A bell tinkles as another traveler shoulders their pack. + step_back_tally_gold: You step back, mentally tallying what you can afford. generic: town_noise_blanket: You pause; the town noise folds around you like a blanket. grain_prices_argument: Someone nearby argues about grain prices in good humor. diff --git a/frontend/src/i18n/loadLocales.ts b/frontend/src/i18n/loadLocales.ts index da28304..91ac5b5 100644 --- a/frontend/src/i18n/loadLocales.ts +++ b/frontend/src/i18n/loadLocales.ts @@ -36,6 +36,46 @@ export const TOWN_VISIT_SLUG_ORDER: Record = { 'squint_spine_legend', 'promise_listen_worth_it', ], + bounty_hunter: [ + 'scrolls_wax_desk', + 'ink_stained_map_tap', + 'busy_roads_noncommittal', + 'draft_parchment_smell', + 'squint_spine_legend', + 'promise_listen_worth_it', + ], + elder: [ + 'scrolls_wax_desk', + 'ink_stained_map_tap', + 'busy_roads_noncommittal', + 'draft_parchment_smell', + 'squint_spine_legend', + 'promise_listen_worth_it', + ], + armorer: [ + 'crates_in_shade', + 'practiced_tired_smile', + 'chalk_prices_twice', + 'rumors_bandits_carts', + 'bell_traveler_pack', + 'step_back_tally_gold', + ], + weapon: [ + 'crates_in_shade', + 'practiced_tired_smile', + 'chalk_prices_twice', + 'rumors_bandits_carts', + 'bell_traveler_pack', + 'step_back_tally_gold', + ], + jeweler: [ + 'crates_in_shade', + 'practiced_tired_smile', + 'chalk_prices_twice', + 'rumors_bandits_carts', + 'bell_traveler_pack', + 'step_back_tally_gold', + ], generic: [ 'town_noise_blanket', 'grain_prices_argument', diff --git a/frontend/src/i18n/ru.yml b/frontend/src/i18n/ru.yml index 28592be..5eb2dff 100644 --- a/frontend/src/i18n/ru.yml +++ b/frontend/src/i18n/ru.yml @@ -93,7 +93,12 @@ ui: failedToAbandonQuest: 'Не удалось отменить задание' completed: 'Завершено' questGiver: 'Квестодатель' + bountyHunter: 'Охотник за головами' + elder: 'Старейшина' merchant: 'Торговец' + armorer: 'Бронник' + weaponSmith: 'Кузнец' + jeweler: 'Ювелир' healer: 'Целитель' npc: 'NPC' buyPotion: 'Купить зелье' @@ -598,6 +603,41 @@ town_npc_visit: stamp_ink_thumb: 'Чернила на пальце — второй печати хватает.' reward_bag_heavier: 'Мешок с наградой выглядит тяжелее совести.' last_hero_failed_joke: 'Шутят, что прошлый герой «ошибся вверх». Ха.' + bounty_hunter: + scrolls_wax_desk: 'Стол завален свитками и сургучными печатями.' + ink_stained_map_tap: 'По карте стучит перстью в чернильных пятнах.' + busy_roads_noncommittal: '— Шумные дороги, — говорят они; ты без обязательств соглашаешься.' + draft_parchment_smell: 'Сквозняк несёт запах старой бумаги.' + squint_spine_legend: 'Щурятся, будто меряют тебя легендой напротив.' + promise_listen_worth_it: 'Ты обещаешь слушать; обещают, что оно того стоит.' + elder: + scrolls_wax_desk: 'Стол завален свитками и сургучными печатями.' + ink_stained_map_tap: 'По карте стучит перстью в чернильных пятнах.' + busy_roads_noncommittal: '— Шумные дороги, — говорят они; ты без обязательств соглашаешься.' + draft_parchment_smell: 'Сквозняк несёт запах старой бумаги.' + squint_spine_legend: 'Щурятся, будто меряют тебя легендой напротив.' + promise_listen_worth_it: 'Ты обещаешь слушать; обещают, что оно того стоит.' + armorer: + crates_in_shade: 'Ты окинул взглядом ящики и узлы, сложенные в тени.' + practiced_tired_smile: 'Торговец здоровается отработанной, усталой улыбкой.' + chalk_prices_twice: 'Цены на меле перечёркнуты дважды — налог надежды на дороге.' + rumors_bandits_carts: 'Вы обмениваетесь слухами о разбойниках и сломанных осях.' + bell_traveler_pack: 'Звенит колокольчик: ещё один путник взваливает рюкзак.' + step_back_tally_gold: 'Ты отступаешь, устно подсчитывая, на что хватит золота.' + weapon: + crates_in_shade: 'Ты окинул взглядом ящики и узлы, сложенные в тени.' + practiced_tired_smile: 'Торговец здоровается отработанной, усталой улыбкой.' + chalk_prices_twice: 'Цены на меле перечёркнуты дважды — налог надежды на дороге.' + rumors_bandits_carts: 'Вы обмениваетесь слухами о разбойниках и сломанных осях.' + bell_traveler_pack: 'Звенит колокольчик: ещё один путник взваливает рюкзак.' + step_back_tally_gold: 'Ты отступаешь, устно подсчитывая, на что хватит золота.' + jeweler: + crates_in_shade: 'Ты окинул взглядом ящики и узлы, сложенные в тени.' + practiced_tired_smile: 'Торговец здоровается отработанной, усталой улыбкой.' + chalk_prices_twice: 'Цены на меле перечёркнуты дважды — налог надежды на дороге.' + rumors_bandits_carts: 'Вы обмениваетесь слухами о разбойниках и сломанных осях.' + bell_traveler_pack: 'Звенит колокольчик: ещё один путник взваливает рюкзак.' + step_back_tally_gold: 'Ты отступаешь, устно подсчитывая, на что хватит золота.' generic: town_noise_blanket: 'Ты замираешь; городской шум обволакивает, как одеяло.' grain_prices_argument: 'Рядом в шутку спорят о цене на зерно.' diff --git a/frontend/src/i18n/types.ts b/frontend/src/i18n/types.ts index 9d1751d..6bcbade 100644 --- a/frontend/src/i18n/types.ts +++ b/frontend/src/i18n/types.ts @@ -93,7 +93,12 @@ export interface Translations { failedToAbandonQuest: string; completed: string; questGiver: string; + bountyHunter: string; + elder: string; merchant: string; + armorer: string; + weaponSmith: string; + jeweler: string; healer: string; npc: string; buyPotion: string; diff --git a/frontend/src/ui/NPCDialog.tsx b/frontend/src/ui/NPCDialog.tsx index 0cdea0f..ae71486 100644 --- a/frontend/src/ui/NPCDialog.tsx +++ b/frontend/src/ui/NPCDialog.tsx @@ -223,25 +223,56 @@ const disabledBtnStyle: CSSProperties = { // ---- NPC Type Info ---- +const GEAR_VENDOR_TYPES = new Set(['merchant', 'armorer', 'weapon', 'jeweler']); +const QUEST_NPC_TYPES = new Set(['quest_giver', 'bounty_hunter', 'elder']); + +function isGearVendorType(t: string): boolean { + return GEAR_VENDOR_TYPES.has(t); +} + +function isQuestNPCType(t: string): boolean { + return QUEST_NPC_TYPES.has(t); +} + function npcTypeIcon(type: string): string { switch (type) { case 'quest_giver': - return '\u2753'; // question mark + case 'bounty_hunter': + return '\u2694\uFE0F'; + case 'elder': + return '\uD83D\uDCDC'; case 'merchant': - return '\uD83D\uDCB0'; // money bag + case 'armorer': + case 'weapon': + case 'jeweler': + return '\uD83D\uDCB0'; case 'healer': - return '\u2764\uFE0F'; // heart + return '\u2764\uFE0F'; default: - return '\uD83D\uDDE3\uFE0F'; // speaking + return '\uD83D\uDDE3\uFE0F'; } } function npcTypeLabel(type: string, tr: ReturnType): string { switch (type) { - case 'quest_giver': return tr.questGiver; - case 'merchant': return tr.merchant; - case 'healer': return tr.healer; - default: return tr.npc; + case 'quest_giver': + return tr.questGiver; + case 'bounty_hunter': + return tr.bountyHunter; + case 'elder': + return tr.elder; + case 'merchant': + return tr.merchant; + case 'armorer': + return tr.armorer; + case 'weapon': + return tr.weaponSmith; + case 'jeweler': + return tr.jeweler; + case 'healer': + return tr.healer; + default: + return tr.npc; } } @@ -327,9 +358,9 @@ export function NPCDialog({ }; }, [telegramId, townTourWs]); - // Merchant: roll stock when the shop opens (server-tier gear for this town). + // Gear vendors: roll stock when the shop opens (server-tier gear for this town). useEffect(() => { - if (npc.type !== 'merchant') return; + if (!isGearVendorType(npc.type)) return; let cancelled = false; setMerchantStockLoading(true); setMerchantStockError(false); @@ -355,9 +386,9 @@ export function NPCDialog({ // eslint-disable-next-line react-hooks/exhaustive-deps -- getHeroWorldPosition may be unstable from parent }, [npc.id, npc.type, telegramId]); - // Fetch available quests for quest giver NPCs + // Fetch available quests for bounty / elder (and legacy quest_giver). useEffect(() => { - if (npc.type !== 'quest_giver') return; + if (!isQuestNPCType(npc.type)) return; setLoading(true); getNPCQuests(npc.id, telegramId) .then((qs) => setAvailableQuests(Array.isArray(qs) ? qs : [])) @@ -545,8 +576,8 @@ export function NPCDialog({ {/* Body */}
- {/* ---- Quest Giver ---- */} - {npc.type === 'quest_giver' && ( + {/* ---- Quest NPCs ---- */} + {isQuestNPCType(npc.type) && ( <> {/* Completed quests — claim first */} {npcCompletedQuests.length > 0 && ( @@ -695,8 +726,8 @@ export function NPCDialog({ )} - {/* ---- Merchant ---- */} - {npc.type === 'merchant' && ( + {/* ---- Gear vendors ---- */} + {isGearVendorType(npc.type) && ( <>
{tr.shopLabel}
{merchantStockLoading ? ( diff --git a/frontend/src/ui/NPCInteraction.tsx b/frontend/src/ui/NPCInteraction.tsx index 9ee7884..d619919 100644 --- a/frontend/src/ui/NPCInteraction.tsx +++ b/frontend/src/ui/NPCInteraction.tsx @@ -81,6 +81,14 @@ const actionBtnStyle: CSSProperties = { // ---- NPC appearance ---- +function isGearVendorType(t: string): boolean { + return t === 'merchant' || t === 'armorer' || t === 'weapon' || t === 'jeweler'; +} + +function isQuestNPCType(t: string): boolean { + return t === 'quest_giver' || t === 'bounty_hunter' || t === 'elder'; +} + function npcColor( type: string, tr: ReturnType, @@ -88,8 +96,18 @@ function npcColor( switch (type) { case 'quest_giver': return { bg: 'rgba(218, 165, 32, 0.2)', icon: '!', text: tr.questGiver }; + case 'bounty_hunter': + return { bg: 'rgba(218, 165, 32, 0.2)', icon: '!', text: tr.bountyHunter }; + case 'elder': + return { bg: 'rgba(200, 170, 120, 0.22)', icon: '\u2020', text: tr.elder }; case 'merchant': return { bg: 'rgba(68, 170, 85, 0.2)', icon: '$', text: tr.merchant }; + case 'armorer': + return { bg: 'rgba(90, 130, 200, 0.2)', icon: '\u25C9', text: tr.armorer }; + case 'weapon': + return { bg: 'rgba(200, 120, 70, 0.2)', icon: '\u2694', text: tr.weaponSmith }; + case 'jeweler': + return { bg: 'rgba(180, 100, 220, 0.2)', icon: '\u2666', text: tr.jeweler }; case 'healer': return { bg: 'rgba(220, 80, 80, 0.2)', icon: '+', text: tr.healer }; default: @@ -110,28 +128,20 @@ export function NPCInteraction({ const info = npcColor(npc.type, tr); const handleAction = useCallback(() => { - switch (npc.type) { - case 'quest_giver': - onViewQuests(npc); - break; - case 'merchant': - case 'healer': - onOpenServiceDialog(npc); - break; + if (isQuestNPCType(npc.type)) { + onViewQuests(npc); + return; + } + if (isGearVendorType(npc.type) || npc.type === 'healer') { + onOpenServiceDialog(npc); } }, [npc, onViewQuests, onOpenServiceDialog]); const actionLabel = (() => { - switch (npc.type) { - case 'quest_giver': - return tr.viewQuests; - case 'merchant': - return tr.openMerchantShop; - case 'healer': - return tr.openHealerServices; - default: - return tr.npcInteractTalk; - } + if (isQuestNPCType(npc.type)) return tr.viewQuests; + if (isGearVendorType(npc.type)) return tr.openMerchantShop; + if (npc.type === 'healer') return tr.openHealerServices; + return tr.npcInteractTalk; })(); return ( @@ -148,9 +158,13 @@ export function NPCInteraction({ style={{ ...npcIconStyle, backgroundColor: info.bg, - color: npc.type === 'quest_giver' ? '#ffd700' : - npc.type === 'merchant' ? '#88dd88' : - npc.type === 'healer' ? '#ff6666' : '#aaa', + color: isQuestNPCType(npc.type) + ? '#ffd700' + : isGearVendorType(npc.type) + ? '#88dd88' + : npc.type === 'healer' + ? '#ff6666' + : '#aaa', }} > {info.icon} @@ -181,16 +195,20 @@ export function NPCInteraction({