master
Denis Ranneft 1 month ago
parent 3c9c811201
commit 9d182cd39b

@ -31,6 +31,8 @@ Each town occupies a rectangular region roughly 15x15 tiles centered on the road
## 2. NPC Types ## 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. Three NPC archetypes for MVP. Each NPC belongs to exactly one town.
| Type | Role | Interaction | | Type | Role | Interaction |

@ -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) idx := graph.TownOrderIndex(hm.CurrentTownID)
if idx < 0 { if idx < 0 {
if d := hm.firstOutgoingDestination(graph); d != 0 { if d := hm.firstOutgoingDestination(graph); d != 0 {
@ -2372,7 +2362,7 @@ func ProcessSingleHeroMovementTick(
if sender != nil { if sender != nil {
hm.WanderingMerchantDeadline = now.Add(time.Duration(cfg.WanderingMerchantPromptTimeoutMs) * time.Millisecond) hm.WanderingMerchantDeadline = now.Add(time.Duration(cfg.WanderingMerchantPromptTimeoutMs) * time.Millisecond)
sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{ 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, Role: "alms", DialogueKey: model.WanderingMerchantDialogueKey, Cost: cost,
}) })
} }
@ -2492,7 +2482,7 @@ func ProcessSingleHeroMovementTick(
hm.WanderingMerchantDeadline = now.Add(time.Duration(cfg.WanderingMerchantPromptTimeoutMs) * time.Millisecond) hm.WanderingMerchantDeadline = now.Add(time.Duration(cfg.WanderingMerchantPromptTimeoutMs) * time.Millisecond)
sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{ sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{
NPCID: 0, NPCID: 0,
NPCName: "Wandering Merchant", NPCName: "Gillen Porter",
NPCNameKey: model.WanderingMerchantNPCKey, NPCNameKey: model.WanderingMerchantNPCKey,
Role: "wandering merchant", Role: "wandering merchant",
DialogueKey: model.WanderingMerchantDialogueKey, DialogueKey: model.WanderingMerchantDialogueKey,

@ -290,7 +290,7 @@ func (s *OfflineSimulator) applyOfflineTownTourNPCVisit(ctx context.Context, her
cfg := tuning.Get() cfg := tuning.Get()
tryQuest := func() bool { tryQuest := func() bool {
if npc.Type != "quest_giver" || s.questStore == nil { if !model.IsQuestOfferNPCType(npc.Type) || s.questStore == nil {
return false return false
} }
hqs, err := s.questStore.ListHeroQuests(ctx, heroID) 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) s.logger.Warn("offline town tour: list quests by npc", "error", err)
return false return false
} }
offered = model.FilterQuestTemplatesByNPCType(offered, npc.Type)
for _, q := range offered { for _, q := range offered {
if _, ok := taken[q.ID]; ok { if _, ok := taken[q.ID]; ok {
continue continue
@ -337,10 +338,11 @@ func (s *OfflineSimulator) applyOfflineTownTourNPCVisit(ctx context.Context, her
return return
} }
if npc.Type == "merchant" { if model.IsGearVendorType(npc.Type) {
gearCost := tuning.EffectiveTownMerchantGearCost(townLv) gearCost := tuning.EffectiveTownMerchantGearCost(townLv)
if s.gearStore != nil && gearCost > 0 && h.Gold >= gearCost { 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) { if len(items) > 0 && TownMerchantRollIsUpgrade(h, items[0], now) {
h.Gold -= gearCost h.Gold -= gearCost
drop, err := ApplyPreparedTownMerchantPurchase(ctx, s.gearStore, h, items[0], now) 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 share := cfg.MerchantTownAutoSellShare
if share <= 0 || share > 1 { if share <= 0 || share > 1 {
share = tuning.DefaultValues().MerchantTownAutoSellShare share = tuning.DefaultValues().MerchantTownAutoSellShare
@ -416,7 +418,7 @@ func (s *OfflineSimulator) applyOfflineTownTourNPCVisit(ctx context.Context, her
return return
} }
if npc.Type == "quest_giver" && al != nil { if model.IsQuestOfferNPCType(npc.Type) && al != nil {
al(heroID, model.AdventureLogLine{ al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{ Event: &model.AdventureLogEvent{
Code: model.LogPhraseQuestGiverChecked, Code: model.LogPhraseQuestGiverChecked,

@ -14,10 +14,17 @@ import (
// RollTownMerchantStockItems generates `count` gear rows for town-tier stock (one roll per slot order, unique slots first). // 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 { 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 { if count < 1 {
count = 1 count = 1
} }
slots := model.AllEquipmentSlots if len(slots) == 0 {
slots = model.AllEquipmentSlots
}
if count > len(slots) { if count > len(slots) {
count = len(slots) count = len(slots)
} }

@ -431,7 +431,7 @@ func processTownTourMovement(
TownNameKey: townNameKey, TownNameKey: townNameKey,
WorldX: ex.TownTourStandX, WorldY: ex.TownTourStandY, WorldX: ex.TownTourStandX, WorldY: ex.TownTourStandY,
}) })
legacyMerchantSell := npc.Type == "merchant" legacyMerchantSell := model.IsGearVendorType(npc.Type)
if legacyMerchantSell { if legacyMerchantSell {
share := cfg.MerchantTownAutoSellShare share := cfg.MerchantTownAutoSellShare
if share <= 0 || share > 1 { if share <= 0 || share > 1 {

@ -465,7 +465,7 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, model.NPCEventResponse{ writeJSON(w, http.StatusOK, model.NPCEventResponse{
Type: "npc_event", Type: "npc_event",
NPC: model.NPCEventNPC{ NPC: model.NPCEventNPC{
Name: "Wandering Merchant", Name: "Gillen Porter",
NameKey: model.WanderingMerchantNPCKey, NameKey: model.WanderingMerchantNPCKey,
Role: "alms", Role: "alms",
}, },

@ -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. // 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 { if npcID == 0 {
return nil, nil, nil, fmt.Errorf("npcId is required") 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 { if npc == nil {
return nil, nil, nil, fmt.Errorf("npc not found") return nil, nil, nil, fmt.Errorf("npc not found")
} }
if wantNPCType != "" && npc.Type != wantNPCType { if len(allowedTypes) > 0 {
return nil, nil, nil, fmt.Errorf("npc type mismatch") 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) town, err := h.questStore.GetTown(ctx, npc.TownID)
if err != nil { if err != nil {
@ -209,7 +219,7 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) {
var actions []model.NPCInteractAction var actions []model.NPCInteractAction
switch npc.Type { switch npc.Type {
case "quest_giver": case model.NPCTypeBounty, model.NPCTypeElder, model.NPCTypeQuestGiver:
refreshHours := tuning.EffectiveQuestOfferRefreshHours() refreshHours := tuning.EffectiveQuestOfferRefreshHours()
if refreshHours <= 0 { if refreshHours <= 0 {
refreshHours = 2 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)) gearCost := tuning.EffectiveTownMerchantGearCost(game.TownEffectiveLevel(town))
actions = append(actions, model.NPCInteractAction{ actions = append(actions, model.NPCInteractAction{
ActionType: "shop_item", ActionType: "shop_item",
@ -799,7 +809,8 @@ func (h *NPCHandler) BuyTownMerchantGear(w http.ResponseWriter, r *http.Request)
return 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 { if err != nil {
msg := err.Error() msg := err.Error()
switch msg { 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"}) writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "world engine unavailable"})
return 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 { if err != nil {
msg := err.Error() msg := err.Error()
switch msg { switch msg {
@ -949,7 +961,8 @@ func (h *NPCHandler) MerchantStock(w http.ResponseWriter, r *http.Request) {
} }
townLv := game.TownEffectiveLevel(town) townLv := game.TownEffectiveLevel(town)
n := tuning.EffectiveMerchantTownStockCount() n := tuning.EffectiveMerchantTownStockCount()
items := game.RollTownMerchantStockItems(townLv, n) slots := model.GearVendorSlots(npc.Type)
items := game.RollTownMerchantStockItemsForSlots(townLv, n, slots)
costs := make([]int64, len(items)) costs := make([]int64, len(items))
for i, it := range items { for i, it := range items {
if it == nil { if it == nil {

@ -122,7 +122,7 @@ func (h *QuestHandler) ListBuildingsByTown(w http.ResponseWriter, r *http.Reques
// ListQuestsByNPC returns quests offered by an NPC. // ListQuestsByNPC returns quests offered by an NPC.
// GET /api/v1/npcs/{npcId}/quests // 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). // Without telegramId, returns all templates for that NPC (catalog / tools).
func (h *QuestHandler) ListQuestsByNPC(w http.ResponseWriter, r *http.Request) { func (h *QuestHandler) ListQuestsByNPC(w http.ResponseWriter, r *http.Request) {
npcIDStr := chi.URLParam(r, "npcId") npcIDStr := chi.URLParam(r, "npcId")

@ -41,9 +41,15 @@ func Run(ctx context.Context, pool *pgxpool.Pool, dir string) error {
var files []string var files []string
for _, e := range entries { for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { name := e.Name()
files = append(files, 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) sort.Strings(files)

@ -132,3 +132,13 @@ func TownVisitRandomPhraseKey(npcType string) string {
} }
return "town_visit." + keyType + "." + slugs[rand.Intn(len(slugs))] 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
}

@ -32,7 +32,11 @@ func TestTownVisitPhraseKeyUsesSlugs(t *testing.T) {
func TestTownVisitRandomPhraseKeyNonEmpty(t *testing.T) { func TestTownVisitRandomPhraseKeyNonEmpty(t *testing.T) {
for i := 0; i < 20; i++ { 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.") { if k == "" || len(k) < len("town_visit.merchant.") {
t.Fatalf("unexpected key %q", k) t.Fatalf("unexpected key %q", k)
} }

@ -4,7 +4,7 @@ package model
type TownBuilding struct { type TownBuilding struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TownID int64 `json:"townId"` 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"` OffsetX float64 `json:"offsetX"`
OffsetY float64 `json:"offsetY"` OffsetY float64 `json:"offsetY"`
Facing string `json:"facing"` // north, south, east, west Facing string `json:"facing"` // north, south, east, west

@ -21,7 +21,7 @@ type NPC struct {
TownID int64 `json:"townId"` TownID int64 `json:"townId"`
Name string `json:"name"` Name string `json:"name"`
NameKey string `json:"nameKey,omitempty"` 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"` OffsetX float64 `json:"offsetX"`
OffsetY float64 `json:"offsetY"` OffsetY float64 `json:"offsetY"`
BuildingID *int64 `json:"buildingId,omitempty"` BuildingID *int64 `json:"buildingId,omitempty"`

@ -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. // 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. // 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) { 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) all, err := s.ListQuestsByNPCForHeroLevel(ctx, npcID, heroLevel)
if err != nil { if err != nil {
return nil, err return nil, err
} }
all = model.FilterQuestTemplatesByNPCType(all, npcRow.Type)
takenIDs, err := s.HeroTakenQuestTemplateIDs(ctx, heroID) takenIDs, err := s.HeroTakenQuestTemplateIDs(ctx, heroID)
if err != nil { if err != nil {
return nil, err return nil, err

@ -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.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` | | `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 ## 1) Monster Model Catalog
Naming convention: Naming convention:

@ -1618,7 +1618,10 @@ export function App() {
potionCost={npcShopCosts.potionCost} potionCost={npcShopCosts.potionCost}
healCost={npcShopCosts.healCost} healCost={npcShopCosts.healCost}
getHeroWorldPosition={() => engineRef.current?.getHeroDisplayWorldPosition() ?? { x: 0, y: 0 }} getHeroWorldPosition={() => engineRef.current?.getHeroDisplayWorldPosition() ?? { x: 0, y: 0 }}
onClose={() => setSelectedNPC(null)} onClose={() => {
setNpcInteractionDismissed(dialogNpc.id);
setSelectedNPC(null);
}}
onQuestsChanged={refreshHeroQuests} onQuestsChanged={refreshHeroQuests}
onHeroUpdated={handleNPCHeroUpdated} onHeroUpdated={handleNPCHeroUpdated}
onToast={(message, color) => setToast({ message, color })} onToast={(message, color) => setToast({ message, color })}

@ -945,6 +945,22 @@ export class GameRenderer {
this._drawHouse(gfx, bx, by, w * 1.1, h, rh * 0.8, 0x44aa55, 0x2a5a30, 1); 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._drawTownStall(gfx, bx + w * 0.7, by + 4, scale * 0.6);
this._drawBuildingIcon(gfx, bx, by - h - rh * 0.3, '$', 0x88dd88, scale); 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') { } else if (bt === 'house.healer') {
this._drawHouse(gfx, bx, by, w, h, rh, 0xccccdd, 0x5555aa, 2); this._drawHouse(gfx, bx, by, w, h, rh, 0xccccdd, 0x5555aa, 2);
this._drawBuildingIcon(gfx, bx, by - h - rh * 0.5, '+', 0xff6666, scale); this._drawBuildingIcon(gfx, bx, by - h - rh * 0.5, '+', 0xff6666, scale);
@ -1290,17 +1306,42 @@ export class GameRenderer {
switch (npc.type) { switch (npc.type) {
case 'quest_giver': case 'quest_giver':
case 'bounty_hunter':
bodyColor = 0xdaa520; bodyColor = 0xdaa520;
bodyStroke = 0x8a6510; bodyStroke = 0x8a6510;
iconText = '!'; iconText = '!';
iconColor = 0xffd700; iconColor = 0xffd700;
break; break;
case 'elder':
bodyColor = 0xc4a574;
bodyStroke = 0x7a6040;
iconText = '\u2020';
iconColor = 0xeeddaa;
break;
case 'merchant': case 'merchant':
bodyColor = 0x44aa55; bodyColor = 0x44aa55;
bodyStroke = 0x2a7a3a; bodyStroke = 0x2a7a3a;
iconText = '$'; iconText = '$';
iconColor = 0x88dd88; iconColor = 0x88dd88;
break; 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': case 'healer':
bodyColor = 0xdddddd; bodyColor = 0xdddddd;
bodyStroke = 0x8888aa; bodyStroke = 0x8888aa;

@ -297,7 +297,15 @@ export interface NPC {
townId: number; townId: number;
name: string; name: string;
nameKey?: string; nameKey?: string;
type: 'quest_giver' | 'merchant' | 'healer'; type:
| 'quest_giver'
| 'merchant'
| 'armorer'
| 'weapon'
| 'jeweler'
| 'bounty_hunter'
| 'elder'
| 'healer';
offsetX: number; offsetX: number;
offsetY: number; offsetY: number;
buildingId?: number; buildingId?: number;
@ -382,7 +390,15 @@ export interface NPCData {
id: number; id: number;
name: string; name: string;
nameKey?: string; nameKey?: string;
type: 'quest_giver' | 'merchant' | 'healer'; type:
| 'quest_giver'
| 'merchant'
| 'armorer'
| 'weapon'
| 'jeweler'
| 'bounty_hunter'
| 'elder'
| 'healer';
worldX: number; worldX: number;
worldY: number; worldY: number;
buildingId?: number; buildingId?: number;

@ -1,5 +1,6 @@
import type { Locale } from './localeCodes'; import type { Locale } from './localeCodes';
import { enemyTypeLabel } from './loadLocales'; import { enemyTypeLabel } from './loadLocales';
import { npcByIdKeyLabel } from './npcGeneratedNames';
/** Stable keys aligned with backend migrations / model constants. */ /** Stable keys aligned with backend migrations / model constants. */
export const WANDERING_MERCHANT_NPC_KEY = 'npc.wandering_merchant.v1'; export const WANDERING_MERCHANT_NPC_KEY = 'npc.wandering_merchant.v1';
@ -40,6 +41,7 @@ export const TOWN_ID_TO_NAME_KEY: Record<number, string> = {
29: 'town.sungrasp.v1', 29: 'town.sungrasp.v1',
30: 'town.glimmerford.v1', 30: 'town.glimmerford.v1',
31: 'town.starveil.v1', 31: 'town.starveil.v1',
32: 'town.capital.v1',
}; };
/** Localized town label from numeric `towns.id` (visit_town quest target, etc.). */ /** Localized town label from numeric `towns.id` (visit_town quest target, etc.). */
@ -86,100 +88,129 @@ const TOWNS: Record<string, Bilingual> = {
'town.sungrasp.v1': { en: 'Sungrasp', ru: 'Санграсп' }, 'town.sungrasp.v1': { en: 'Sungrasp', ru: 'Санграсп' },
'town.glimmerford.v1': { en: 'Glimmerford', ru: 'Глиммерфорд' }, 'town.glimmerford.v1': { en: 'Glimmerford', ru: 'Глиммерфорд' },
'town.starveil.v1': { en: 'Starveil', ru: 'Старвейл' }, 'town.starveil.v1': { en: 'Starveil', ru: 'Старвейл' },
'town.capital.v1': { en: 'Capital', ru: 'Столица' },
}; };
const NPCS: Record<string, Bilingual> = { const NPCS: Record<string, Bilingual> = {
'npc.elder_maren.v1': { en: 'Elder Maren', ru: 'Старейшина Марен' }, 'npc.elder_maren.v1': { en: 'Maren Thistlewood', ru: 'Марен Тистлвуд' },
'npc.peddler_finn.v1': { en: 'Peddler Finn', ru: 'Бродячий торговец Финн' }, 'npc.peddler_finn.v1': { en: 'Finn Marlow', ru: 'Финн Марлоу' },
'npc.sister_asha.v1': { en: 'Sister Asha', ru: 'Сестра Аша' }, 'npc.sister_asha.v1': { en: 'Asha Kentwell', ru: 'Аша Кентвелл' },
'npc.guard_halric.v1': { en: 'Guard Halric', ru: 'Страж Халрик' }, 'npc.guard_halric.v1': { en: 'Halric Morrow', ru: 'Халрик Морроу' },
'npc.trader_wynn.v1': { en: 'Trader Wynn', ru: 'Торговец Винн' }, 'npc.trader_wynn.v1': { en: 'Wynn Cartwright', ru: 'Винн Картрайт' },
'npc.scholar_orin.v1': { en: 'Scholar Orin', ru: 'Учёный Орин' }, 'npc.scholar_orin.v1': { en: 'Orin Aldgate', ru: 'Орин Олдгейт' },
'npc.bone_merchant.v1': { en: 'Bone Merchant', ru: 'Торговец костями' }, 'npc.bone_merchant.v1': { en: 'Osbert Waynwood', ru: 'Осберт Вейнвуд' },
'npc.priestess_liora.v1': { en: 'Priestess Liora', ru: 'Жрица Лиора' }, 'npc.priestess_liora.v1': { en: 'Liora Selwyn', ru: 'Лиора Селвин' },
[WANDERING_MERCHANT_NPC_KEY]: { en: 'Wandering Merchant', ru: 'Бродячий торговец' }, 'npc.brandric_thacker.v1': { en: 'Brandric Thacker', ru: 'Брандрик Тэкер' },
'npc.clerk_sera.v1': { en: 'Clerk Sera', ru: 'Клер Сера' }, 'npc.conrad_pitwright.v1': { en: 'Conrad Pitwright', ru: 'Конрад Питрайт' },
'npc.notary_bram.v1': { en: 'Notary Bram', ru: 'Нотариус Брам' }, 'npc.nessa_bramble.v1': { en: 'Nessa Bramble', ru: 'Несса Брамбл' },
'npc.copper_nils.v1': { en: 'Copper Nils', ru: 'Коппер Нилс' }, 'npc.torin_marshwick.v1': { en: 'Torin Marshwick', ru: 'Торин Маршвик' },
'npc.tin_mara.v1': { en: 'Tin Mara', ru: 'Тин Мара' }, 'npc.renulf_broadmere.v1': { en: 'Renulf Broadmere', ru: 'Ренульф Бродмир' },
'npc.sister_calm.v1': { en: 'Sister Calm', ru: 'Сестра Калм' }, 'npc.kael_ironwright.v1': { en: 'Kael Ironwright', ru: 'Кейл Айронрайт' },
'npc.foreman_rook.v1': { en: 'Foreman Rook', ru: 'Форман Рук' }, 'npc.edmund_cinderwell.v1': { en: 'Edmund Cinderwell', ru: 'Эдмунд Синдервелл' },
'npc.wire_merchant.v1': { en: 'Wire Merchant', ru: 'Торговец проволокой' }, 'npc.aelith_northgate.v1': { en: 'Aelith Northgate', ru: 'Аэлит Нортгейт' },
'npc.bolt_jada.v1': { en: 'Bolt Jada', ru: 'Болт Джада' }, 'npc.dorian_hawke.v1': { en: 'Dorian Hawke', ru: 'Дориан Хоук' },
'npc.sage_mottle.v1': { en: 'Sage Mottle', ru: 'Сейдж Моттл' }, 'npc.mariel_starling.v1': { en: 'Mariel Starling', ru: 'Мариэль Старлинг' },
'npc.warden_pike.v1': { en: 'Warden Pike', ru: 'Варден Пайк' }, 'npc.milo_ropewalk.v1': { en: 'Milo Ropewalk', ru: 'Мило Роупуок' },
'npc.ash_vendor.v1': { en: 'Ash Vendor', ru: 'Торговец золой' }, 'npc.lissa_harcourt.v1': { en: 'Lissa Harcourt', ru: 'Лисса Харкорт' },
'npc.scrap_yori.v1': { en: 'Scrap Yori', ru: 'Скрап Йори' }, 'npc.jasper_kindling.v1': { en: 'Jasper Kindling', ru: 'Джаспер Киндлинг' },
'npc.herb_rill.v1': { en: 'Herb Rill', ru: 'Херб Рилл' }, 'npc.kess_wiley.v1': { en: 'Kess Wiley', ru: 'Кесс Уайли' },
'npc.miller_tove.v1': { en: 'Miller Tove', ru: 'Миллер Тов' }, 'npc.aldwin_relicton.v1': { en: 'Aldwin Relicton', ru: 'Олдвин Реликтон' },
'npc.grain_peddler.v1': { en: 'Grain Peddler', ru: 'Зерновой бродяга' }, 'npc.torvik_grimstad.v1': { en: 'Torvik Grimstad', ru: 'Торвик Гримстад' },
'npc.sack_ren.v1': { en: 'Sack Ren', ru: 'Сак Рен' }, 'npc.morna_fenwick.v1': { en: 'Morna Fenwick', ru: 'Морна Фенвик' },
'npc.brother_salve.v1': { en: 'Brother Salve', ru: 'Брат Сальв' }, 'npc.morah_ellis.v1': { en: 'Morah Ellis', ru: 'Мора Эллис' },
'npc.stone_judge.v1': { en: 'Stone Judge', ru: 'Каменный судья' }, [WANDERING_MERCHANT_NPC_KEY]: { en: 'Gillen Porter', ru: 'Гиллен Портер' },
'npc.edge_trader.v1': { en: 'Edge Trader', ru: 'Торговец с краю' }, 'npc.clerk_sera.v1': { en: 'Sera Whitcomb', ru: 'Сера Уиткомб' },
'npc.crack_merchant.v1': { en: 'Crack Merchant', ru: 'Торговец из трещины' }, 'npc.notary_bram.v1': { en: 'Bram Ashcombe', ru: 'Брам Эшкомб' },
'npc.sister_flint.v1': { en: 'Sister Flint', ru: 'Сестра Флинт' }, 'npc.copper_nils.v1': { en: 'Nils Copperton', ru: 'Нилс Коппертон' },
'npc.starward_oren.v1': { en: 'Starward Oren', ru: 'Старворд Орен' }, 'npc.tin_mara.v1': { en: 'Mara Tinwell', ru: 'Мара Тинвелл' },
'npc.spire_imports.v1': { en: 'Spire Imports', ru: 'Спайр Импортс' }, 'npc.sister_calm.v1': { en: 'Agnes Stillwater', ru: 'Агнес Стилуотер' },
'npc.comet_outfitter.v1': { en: 'Comet Outfitter', ru: 'Комет Аутфиттер' }, 'npc.foreman_rook.v1': { en: 'Rodrick Cantrell', ru: 'Родрик Кантрелл' },
'npc.void_medic.v1': { en: 'Void Medic', ru: 'Медик пустоты' }, 'npc.wire_merchant.v1': { en: 'Wulfric Strand', ru: 'Вульфрик Стрэнд' },
'npc.brine_archivist.v1': { en: 'Brine Archivist', ru: 'Архивариус рассола' }, 'npc.bolt_jada.v1': { en: 'Jada Boltwright', ru: 'Джада Болтрайт' },
'npc.salt_broker.v1': { en: 'Salt Broker', ru: 'Солёный брокер' }, 'npc.sage_mottle.v1': { en: 'Alaric Motlow', ru: 'Аларик Мотлоу' },
'npc.reed_trader.v1': { en: 'Reed Trader', ru: 'Торговец тростником' }, 'npc.warden_pike.v1': { en: 'Percival Pike', ru: 'Персиваль Пайк' },
'npc.mud_healer.v1': { en: 'Mud Healer', ru: 'Грязевой лекарь' }, 'npc.ash_vendor.v1': { en: 'Eadric Ashenford', ru: 'Эадрик Эшенфорд' },
'npc.post_warden.v1': { en: 'Post Warden', ru: 'Страж поста' }, 'npc.scrap_yori.v1': { en: 'Yoric Scarn', ru: 'Йорик Скарн' },
'npc.ironmonger.v1': { en: 'Ironmonger', ru: 'Железный торговец' }, 'npc.herb_rill.v1': { en: 'Rillian Hereward', ru: 'Риллиан Херуорд' },
'npc.rivet_seller.v1': { en: 'Rivet Seller', ru: 'Продавец заклёпок' }, 'npc.miller_tove.v1': { en: 'Tove Millerson', ru: 'Тове Миллерсон' },
'npc.forge_medic.v1': { en: 'Forge Medic', ru: 'Кузнечный медик' }, 'npc.grain_peddler.v1': { en: 'Gareth Grantham', ru: 'Гарет Грантам' },
'npc.bog_chronicler.v1': { en: 'Bog Chronicler', ru: 'Хронист болота' }, 'npc.sack_ren.v1': { en: 'Renulf Sackville', ru: 'Ренульф Саквилл' },
'npc.fen_notary.v1': { en: 'Fen Notary', ru: 'Нотариус топи' }, 'npc.brother_salve.v1': { en: 'Bernard Lukin', ru: 'Бернард Лукин' },
'npc.mire_merchant.v1': { en: 'Mire Merchant', ru: 'Торговец трясиной' }, 'npc.stone_judge.v1': { en: 'Aldwin Grimston', ru: 'Олдвим Гримстон' },
'npc.reed_coin.v1': { en: 'Reed Coin', ru: 'Рид Коин' }, 'npc.edge_trader.v1': { en: 'Edmund Edgerton', ru: 'Эдмунд Эджертон' },
'npc.swamp_mender.v1': { en: 'Swamp Mender', ru: 'Болотный латальщик' }, 'npc.crack_merchant.v1': { en: 'Crispin Aylesford', ru: 'Криспин Эйлсфорд' },
'npc.dune_scout.v1': { en: 'Dune Scout', ru: 'Разведчик дюн' }, 'npc.sister_flint.v1': { en: 'Brunhild Flint', ru: 'Брунхильд Флинт' },
'npc.silt_trader.v1': { en: 'Silt Trader', ru: 'Торговец илом' }, 'npc.starward_oren.v1': { en: 'Oren Starward', ru: 'Орен Старворд' },
'npc.sand_peddler.v1': { en: 'Sand Peddler', ru: 'Песочный бродяга' }, 'npc.spire_imports.v1': { en: 'Simon Spirewell', ru: 'Саймон Спайрвелл' },
'npc.grit_healer.v1': { en: 'Grit Healer', ru: 'Грит‑лекарь' }, 'npc.comet_outfitter.v1': { en: 'Hugh Comstock', ru: 'Хью Комсток' },
'npc.barrow_keeper.v1': { en: 'Barrow Keeper', ru: 'Хранитель курганов' }, 'npc.void_medic.v1': { en: 'Yves Portier', ru: 'Ив Портье' },
'npc.bone_outfitter.v1': { en: 'Bone Outfitter', ru: 'Костяной снаряженец' }, 'npc.brine_archivist.v1': { en: 'Cedric Brinewell', ru: 'Седрик Брайнвелл' },
'npc.cold_peddler.v1': { en: 'Cold Peddler', ru: 'Холодный бродяга' }, 'npc.salt_broker.v1': { en: 'Osmund Salter', ru: 'Осмунд Солтер' },
'npc.shroud_medic.v1': { en: 'Shroud Medic', ru: 'Медик покрова' }, 'npc.reed_trader.v1': { en: 'Rhys Reedman', ru: 'Рис Ридман' },
'npc.mist_ranger.v1': { en: 'Mist Ranger', ru: 'Рейнджер тумана' }, 'npc.mud_healer.v1': { en: 'Godfrey Middleton', ru: 'Годфри Миддлтон' },
'npc.fog_trader.v1': { en: 'Fog Trader', ru: 'Торговец туманом' }, 'npc.post_warden.v1': { en: 'Wystan Postlethwaite', ru: 'Вистан Постлтвейт' },
'npc.dew_merchant.v1': { en: 'Dew Merchant', ru: 'Торговец росой' }, 'npc.ironmonger.v1': { en: 'Ivo Ironside', ru: 'Иво Айронсайд' },
'npc.vapor_healer.v1': { en: 'Vapor Healer', ru: 'Лекарь пара' }, 'npc.rivet_seller.v1': { en: 'Roland Rivett', ru: 'Роланд Риветт' },
'npc.hollow_scribe.v1': { en: 'Hollow Scribe', ru: 'Писарь пустоты' }, 'npc.forge_medic.v1': { en: 'Lucan Forrest', ru: 'Люкан Форрест' },
'npc.mer_imports.v1': { en: 'Mer Imports', ru: 'Мер Импортс' }, 'npc.bog_chronicler.v1': { en: 'Alaric Boghurst', ru: 'Аларик Богхерст' },
'npc.rot_trader.v1': { en: 'Rot Trader', ru: 'Торговец гнилью' }, 'npc.fen_notary.v1': { en: 'Norbert Fenwick', ru: 'Норберт Фенвик' },
'npc.bog_medic.v1': { en: 'Bog Medic', ru: 'Болотный медик' }, 'npc.mire_merchant.v1': { en: 'Miles Myreham', ru: 'Майлз Майрэм' },
'npc.herald_ash.v1': { en: 'Ash Herald', ru: 'Глашатай пепла' }, 'npc.reed_coin.v1': { en: 'Cuthbert Reed', ru: 'Кутберт Рид' },
'npc.cinder_seller.v1': { en: 'Cinder Seller', ru: 'Продавец золы' }, 'npc.swamp_mender.v1': { en: 'Wendel Marsham', ru: 'Вендел Маршам' },
'npc.ember_peddler.v1': { en: 'Ember Peddler', ru: 'Угольный бродяга' }, 'npc.dune_scout.v1': { en: 'Sigurd Dunstan', ru: 'Сигурд Дунстан' },
'npc.ash_healer.v1': { en: 'Ash Healer', ru: 'Пепельный лекарь' }, 'npc.silt_trader.v1': { en: 'Silas Siltwell', ru: 'Сайлас Силтвелл' },
'npc.thorn_watcher.v1': { en: 'Thorn Watcher', ru: 'Дозорный шипов' }, 'npc.sand_peddler.v1': { en: 'Peter Sanderson', ru: 'Питер Сандерсон' },
'npc.briar_trader.v1': { en: 'Briar Trader', ru: 'Торговец шипами' }, 'npc.grit_healer.v1': { en: 'Griselda Holt', ru: 'Грисельда Холт' },
'npc.root_seller.v1': { en: 'Root Seller', ru: 'Продавец корней' }, 'npc.barrow_keeper.v1': { en: 'Bartholomew Howe', ru: 'Бартоломью Хоу' },
'npc.leaf_medic.v1': { en: 'Leaf Medic', ru: 'Лиственный медик' }, 'npc.bone_outfitter.v1': { en: 'Baldwin Bonewright', ru: 'Болдуин Бонрайт' },
'npc.gale_factor.v1': { en: 'Gale Factor', ru: 'Фактор шторма' }, 'npc.cold_peddler.v1': { en: 'Cole Aldridge', ru: 'Кол Олдридж' },
'npc.wind_outfitter.v1': { en: 'Wind Outfitter', ru: 'Ветряной снаряженец' }, 'npc.shroud_medic.v1': { en: 'Shadrach Morrow', ru: 'Шадрак Морроу' },
'npc.gust_peddler.v1': { en: 'Gust Peddler', ru: 'Порывистый бродяга' }, 'npc.mist_ranger.v1': { en: 'Rowan Mistwell', ru: 'Роуан Миствелл' },
'npc.breeze_healer.v1': { en: 'Breeze Healer', ru: 'Лекарь бриза' }, 'npc.fog_trader.v1': { en: 'Fergus Fogarty', ru: 'Фергус Фогарти' },
'npc.frost_archivist.v1': { en: 'Frost Archivist', ru: 'Морозный архивариус' }, 'npc.dew_merchant.v1': { en: 'Dewi Tarrant', ru: 'Дьюи Таррант' },
'npc.rime_trader.v1': { en: 'Rime Trader', ru: 'Торговец инеем' }, 'npc.vapor_healer.v1': { en: 'Vespasian Vale', ru: 'Веспасиан Вейл' },
'npc.hoarfrost_seller.v1': { en: 'Hoarfrost Seller', ru: 'Продавец инея' }, 'npc.hollow_scribe.v1': { en: 'Hugo Holloway', ru: 'Хьюго Холлоуэй' },
'npc.ice_medic.v1': { en: 'Ice Medic', ru: 'Лёд‑медик' }, 'npc.mer_imports.v1': { en: 'Meredith Stowe', ru: 'Мередит Стоу' },
'npc.sun_warden.v1': { en: 'Sun Warden', ru: 'Страж солнца' }, 'npc.rot_trader.v1': { en: 'Roderick Rotherham', ru: 'Родрик Ротерем' },
'npc.cliff_merchant.v1': { en: 'Cliff Merchant', ru: 'Утёсный торговец' }, 'npc.bog_medic.v1': { en: 'Beatrice Boghurst', ru: 'Беатрис Богхерст' },
'npc.crag_peddler.v1': { en: 'Crag Peddler', ru: 'Краг‑бродяга' }, 'npc.herald_ash.v1': { en: 'Ashford Hale', ru: 'Эшфорд Хейл' },
'npc.dust_healer.v1': { en: 'Dust Healer', ru: 'Пыльный лекарь' }, 'npc.cinder_seller.v1': { en: 'Cyril Cinders', ru: 'Сирил Синдерс' },
'npc.ford_marshal.v1': { en: 'Ford Marshal', ru: 'Маршал брода' }, 'npc.ember_peddler.v1': { en: 'Emrys Emberly', ru: 'Эмрис Эмберли' },
'npc.river_trader.v1': { en: 'River Trader', ru: 'Речной торговец' }, 'npc.ash_healer.v1': { en: 'Alicia Ashford', ru: 'Алисия Эшфорд' },
'npc.bridge_seller.v1': { en: 'Bridge Seller', ru: 'Продавец мостов' }, 'npc.thorn_watcher.v1': { en: 'Thorne Hawthorn', ru: 'Торн Хоторн' },
'npc.stream_medic.v1': { en: 'Stream Medic', ru: 'Ручьевой медик' }, 'npc.briar_trader.v1': { en: 'Brian Briarton', ru: 'Брайан Брайартон' },
'npc.veil_seer.v1': { en: 'Veil Seer', ru: 'Видящая завесы' }, 'npc.root_seller.v1': { en: 'Rowan Rootwell', ru: 'Роуан Рутвелл' },
'npc.star_trader.v1': { en: 'Star Trader', ru: 'Звёздный торговец' }, 'npc.leaf_medic.v1': { en: 'Leofric Leaford', ru: 'Леофрик Лиффорд' },
'npc.nebula_peddler.v1': { en: 'Nebula Peddler', ru: 'Туманность‑бродяга' }, 'npc.gale_factor.v1': { en: 'Galfrid Gales', ru: 'Галфрид Гейлс' },
'npc.veil_mender_starveil.v1': { en: 'Veil Mender', 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<string, Bilingual> = { const DIALOGUES: Record<string, Bilingual> = {
@ -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 { export function npcLabel(locale: Locale, key: string | undefined, fallback: string): string {
if (!key) return fallback; if (!key) return fallback;
const byId = npcByIdKeyLabel(locale, key);
if (byId) return byId;
const b = NPCS[key]; const b = NPCS[key];
return b ? pick(locale, b) : fallback; return b ? pick(locale, b) : fallback;
} }

@ -93,7 +93,12 @@ ui:
failedToAbandonQuest: Failed to abandon quest failedToAbandonQuest: Failed to abandon quest
completed: Completed completed: Completed
questGiver: Quest Giver questGiver: Quest Giver
bountyHunter: Bounty Hunter
elder: Elder
merchant: Merchant merchant: Merchant
armorer: Armorer
weaponSmith: Smith
jeweler: Jeweler
healer: Healer healer: Healer
npc: NPC npc: NPC
buyPotion: Buy Potion buyPotion: Buy Potion
@ -598,6 +603,41 @@ town_npc_visit:
stamp_ink_thumb: Ink stains their thumb like a second seal. stamp_ink_thumb: Ink stains their thumb like a second seal.
reward_bag_heavier: The reward bag looks heavier than your conscience. reward_bag_heavier: The reward bag looks heavier than your conscience.
last_hero_failed_joke: They joke that the last hero failed upward. Ha. 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 givers 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 givers 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: generic:
town_noise_blanket: You pause; the town noise folds around you like a blanket. 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. grain_prices_argument: Someone nearby argues about grain prices in good humor.

@ -36,6 +36,46 @@ export const TOWN_VISIT_SLUG_ORDER: Record<string, readonly string[]> = {
'squint_spine_legend', 'squint_spine_legend',
'promise_listen_worth_it', '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: [ generic: [
'town_noise_blanket', 'town_noise_blanket',
'grain_prices_argument', 'grain_prices_argument',

@ -93,7 +93,12 @@ ui:
failedToAbandonQuest: 'Не удалось отменить задание' failedToAbandonQuest: 'Не удалось отменить задание'
completed: 'Завершено' completed: 'Завершено'
questGiver: 'Квестодатель' questGiver: 'Квестодатель'
bountyHunter: 'Охотник за головами'
elder: 'Старейшина'
merchant: 'Торговец' merchant: 'Торговец'
armorer: 'Бронник'
weaponSmith: 'Кузнец'
jeweler: 'Ювелир'
healer: 'Целитель' healer: 'Целитель'
npc: 'NPC' npc: 'NPC'
buyPotion: 'Купить зелье' buyPotion: 'Купить зелье'
@ -598,6 +603,41 @@ town_npc_visit:
stamp_ink_thumb: 'Чернила на пальце — второй печати хватает.' stamp_ink_thumb: 'Чернила на пальце — второй печати хватает.'
reward_bag_heavier: 'Мешок с наградой выглядит тяжелее совести.' reward_bag_heavier: 'Мешок с наградой выглядит тяжелее совести.'
last_hero_failed_joke: 'Шутят, что прошлый герой «ошибся вверх». Ха.' 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: generic:
town_noise_blanket: 'Ты замираешь; городской шум обволакивает, как одеяло.' town_noise_blanket: 'Ты замираешь; городской шум обволакивает, как одеяло.'
grain_prices_argument: 'Рядом в шутку спорят о цене на зерно.' grain_prices_argument: 'Рядом в шутку спорят о цене на зерно.'

@ -93,7 +93,12 @@ export interface Translations {
failedToAbandonQuest: string; failedToAbandonQuest: string;
completed: string; completed: string;
questGiver: string; questGiver: string;
bountyHunter: string;
elder: string;
merchant: string; merchant: string;
armorer: string;
weaponSmith: string;
jeweler: string;
healer: string; healer: string;
npc: string; npc: string;
buyPotion: string; buyPotion: string;

@ -223,25 +223,56 @@ const disabledBtnStyle: CSSProperties = {
// ---- NPC Type Info ---- // ---- 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 { function npcTypeIcon(type: string): string {
switch (type) { switch (type) {
case 'quest_giver': case 'quest_giver':
return '\u2753'; // question mark case 'bounty_hunter':
return '\u2694\uFE0F';
case 'elder':
return '\uD83D\uDCDC';
case 'merchant': case 'merchant':
return '\uD83D\uDCB0'; // money bag case 'armorer':
case 'weapon':
case 'jeweler':
return '\uD83D\uDCB0';
case 'healer': case 'healer':
return '\u2764\uFE0F'; // heart return '\u2764\uFE0F';
default: default:
return '\uD83D\uDDE3\uFE0F'; // speaking return '\uD83D\uDDE3\uFE0F';
} }
} }
function npcTypeLabel(type: string, tr: ReturnType<typeof useT>): string { function npcTypeLabel(type: string, tr: ReturnType<typeof useT>): string {
switch (type) { switch (type) {
case 'quest_giver': return tr.questGiver; case 'quest_giver':
case 'merchant': return tr.merchant; return tr.questGiver;
case 'healer': return tr.healer; case 'bounty_hunter':
default: return tr.npc; 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]); }, [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(() => { useEffect(() => {
if (npc.type !== 'merchant') return; if (!isGearVendorType(npc.type)) return;
let cancelled = false; let cancelled = false;
setMerchantStockLoading(true); setMerchantStockLoading(true);
setMerchantStockError(false); setMerchantStockError(false);
@ -355,9 +386,9 @@ export function NPCDialog({
// eslint-disable-next-line react-hooks/exhaustive-deps -- getHeroWorldPosition may be unstable from parent // eslint-disable-next-line react-hooks/exhaustive-deps -- getHeroWorldPosition may be unstable from parent
}, [npc.id, npc.type, telegramId]); }, [npc.id, npc.type, telegramId]);
// Fetch available quests for quest giver NPCs // Fetch available quests for bounty / elder (and legacy quest_giver).
useEffect(() => { useEffect(() => {
if (npc.type !== 'quest_giver') return; if (!isQuestNPCType(npc.type)) return;
setLoading(true); setLoading(true);
getNPCQuests(npc.id, telegramId) getNPCQuests(npc.id, telegramId)
.then((qs) => setAvailableQuests(Array.isArray(qs) ? qs : [])) .then((qs) => setAvailableQuests(Array.isArray(qs) ? qs : []))
@ -545,8 +576,8 @@ export function NPCDialog({
{/* Body */} {/* Body */}
<div style={bodyStyle}> <div style={bodyStyle}>
{/* ---- Quest Giver ---- */} {/* ---- Quest NPCs ---- */}
{npc.type === 'quest_giver' && ( {isQuestNPCType(npc.type) && (
<> <>
{/* Completed quests — claim first */} {/* Completed quests — claim first */}
{npcCompletedQuests.length > 0 && ( {npcCompletedQuests.length > 0 && (
@ -695,8 +726,8 @@ export function NPCDialog({
</> </>
)} )}
{/* ---- Merchant ---- */} {/* ---- Gear vendors ---- */}
{npc.type === 'merchant' && ( {isGearVendorType(npc.type) && (
<> <>
<div style={sectionTitleStyle}>{tr.shopLabel}</div> <div style={sectionTitleStyle}>{tr.shopLabel}</div>
{merchantStockLoading ? ( {merchantStockLoading ? (

@ -81,6 +81,14 @@ const actionBtnStyle: CSSProperties = {
// ---- NPC appearance ---- // ---- 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( function npcColor(
type: string, type: string,
tr: ReturnType<typeof useT>, tr: ReturnType<typeof useT>,
@ -88,8 +96,18 @@ function npcColor(
switch (type) { switch (type) {
case 'quest_giver': case 'quest_giver':
return { bg: 'rgba(218, 165, 32, 0.2)', icon: '!', text: tr.questGiver }; 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': case 'merchant':
return { bg: 'rgba(68, 170, 85, 0.2)', icon: '$', text: tr.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': case 'healer':
return { bg: 'rgba(220, 80, 80, 0.2)', icon: '+', text: tr.healer }; return { bg: 'rgba(220, 80, 80, 0.2)', icon: '+', text: tr.healer };
default: default:
@ -110,28 +128,20 @@ export function NPCInteraction({
const info = npcColor(npc.type, tr); const info = npcColor(npc.type, tr);
const handleAction = useCallback(() => { const handleAction = useCallback(() => {
switch (npc.type) { if (isQuestNPCType(npc.type)) {
case 'quest_giver': onViewQuests(npc);
onViewQuests(npc); return;
break; }
case 'merchant': if (isGearVendorType(npc.type) || npc.type === 'healer') {
case 'healer': onOpenServiceDialog(npc);
onOpenServiceDialog(npc);
break;
} }
}, [npc, onViewQuests, onOpenServiceDialog]); }, [npc, onViewQuests, onOpenServiceDialog]);
const actionLabel = (() => { const actionLabel = (() => {
switch (npc.type) { if (isQuestNPCType(npc.type)) return tr.viewQuests;
case 'quest_giver': if (isGearVendorType(npc.type)) return tr.openMerchantShop;
return tr.viewQuests; if (npc.type === 'healer') return tr.openHealerServices;
case 'merchant': return tr.npcInteractTalk;
return tr.openMerchantShop;
case 'healer':
return tr.openHealerServices;
default:
return tr.npcInteractTalk;
}
})(); })();
return ( return (
@ -148,9 +158,13 @@ export function NPCInteraction({
style={{ style={{
...npcIconStyle, ...npcIconStyle,
backgroundColor: info.bg, backgroundColor: info.bg,
color: npc.type === 'quest_giver' ? '#ffd700' : color: isQuestNPCType(npc.type)
npc.type === 'merchant' ? '#88dd88' : ? '#ffd700'
npc.type === 'healer' ? '#ff6666' : '#aaa', : isGearVendorType(npc.type)
? '#88dd88'
: npc.type === 'healer'
? '#ff6666'
: '#aaa',
}} }}
> >
{info.icon} {info.icon}
@ -181,16 +195,20 @@ export function NPCInteraction({
<button <button
style={{ style={{
...actionBtnStyle, ...actionBtnStyle,
backgroundColor: backgroundColor: isQuestNPCType(npc.type)
npc.type === 'quest_giver' ? 'rgba(68, 170, 255, 0.2)' : ? 'rgba(68, 170, 255, 0.2)'
npc.type === 'merchant' ? 'rgba(68, 200, 68, 0.2)' : : isGearVendorType(npc.type)
npc.type === 'healer' ? 'rgba(200, 68, 68, 0.2)' : ? 'rgba(68, 200, 68, 0.2)'
'rgba(100, 100, 100, 0.15)', : npc.type === 'healer'
color: ? 'rgba(200, 68, 68, 0.2)'
npc.type === 'quest_giver' ? '#66bbff' : : 'rgba(100, 100, 100, 0.15)',
npc.type === 'merchant' ? '#88dd88' : color: isQuestNPCType(npc.type)
npc.type === 'healer' ? '#ff8888' : ? '#66bbff'
'#666', : isGearVendorType(npc.type)
? '#88dd88'
: npc.type === 'healer'
? '#ff8888'
: '#666',
cursor: 'pointer', cursor: 'pointer',
}} }}
onClick={handleAction} onClick={handleAction}

Loading…
Cancel
Save