Compare commits

..

3 Commits

Author SHA1 Message Date
Denis Ranneft d34d428d8a npcs 1 month ago
Denis Ranneft 9d182cd39b npcs 1 month ago
Denis Ranneft 3c9c811201 some fixes 1 month ago

@ -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 |

@ -1582,6 +1582,7 @@ func (e *Engine) processCombatTickLocked(now time.Time) {
if hm, ok := e.movements[heroID]; ok { if hm, ok := e.movements[heroID]; ok {
hm.Die() hm.Die()
} }
e.persistHeroDeathLocked(heroID, cs.Hero)
dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
e.applyOfflineDigest(dctx, heroID, cs.Hero, now, storage.OfflineDigestDelta{Deaths: 1}) e.applyOfflineDigest(dctx, heroID, cs.Hero, now, storage.OfflineDigestDelta{Deaths: 1})
cancel() cancel()
@ -1614,6 +1615,21 @@ func (e *Engine) processAttackEvent(evt *model.AttackEvent, cs *model.CombatStat
} }
} }
// persistHeroDeathLocked writes the dead hero snapshot immediately so DB state
// never lags behind the live in-memory death state.
// Caller must hold e.mu.
func (e *Engine) persistHeroDeathLocked(heroID int64, hero *model.Hero) {
if e.heroStore == nil || hero == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
err := e.heroStore.Save(ctx, hero)
cancel()
if err != nil && e.logger != nil {
e.logger.Error("persist hero after death", "hero_id", heroID, "error", err)
}
}
// sendDebuffAppliedForString pushes debuff_applied when a debuff proc string is non-empty. // sendDebuffAppliedForString pushes debuff_applied when a debuff proc string is non-empty.
func (e *Engine) sendDebuffAppliedForString(heroID int64, debuffTypeStr string, now time.Time) { func (e *Engine) sendDebuffAppliedForString(heroID int64, debuffTypeStr string, now time.Time) {
if e.sender == nil || debuffTypeStr == "" { if e.sender == nil || debuffTypeStr == "" {
@ -1748,6 +1764,7 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) {
if hm, ok := e.movements[cs.HeroID]; ok { if hm, ok := e.movements[cs.HeroID]; ok {
hm.Die() hm.Die()
} }
e.persistHeroDeathLocked(cs.HeroID, cs.Hero)
dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
e.applyOfflineDigest(dctx, cs.HeroID, cs.Hero, now, storage.OfflineDigestDelta{Deaths: 1}) e.applyOfflineDigest(dctx, cs.HeroID, cs.Hero, now, storage.OfflineDigestDelta{Deaths: 1})
cancel() cancel()

@ -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 {

@ -1289,6 +1289,16 @@ func (h *AdminHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
return return
} }
// Admin UI displays live in-engine state when the hero is online.
// Use that same authoritative snapshot for revive validation to avoid
// false "hero is not dead" when DB lagged behind live movement/combat.
if h.engine != nil {
if hm := h.engine.GetMovements(heroID); hm != nil && hm.Hero != nil {
live := *hm.Hero
hero = &live
}
}
if !game.IsEffectivelyDead(hero) { if !game.IsEffectivelyDead(hero) {
writeJSON(w, http.StatusBadRequest, map[string]string{ writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "hero is not dead", "error": "hero is not dead",

@ -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

@ -0,0 +1,75 @@
package model
// Gear vendor NPC types (town shop stock is restricted by slot set).
const (
NPCTypeMerchant = "merchant" // feet, hands, cloak
NPCTypeArmorer = "armorer" // legs, wrist, chest, head
NPCTypeWeapon = "weapon" // main_hand
NPCTypeJeweler = "jeweler" // finger, neck
NPCTypeHealer = "healer"
NPCTypeBounty = "bounty_hunter"
NPCTypeElder = "elder"
NPCTypeQuestGiver = "quest_giver" // legacy; not used after DB migration
)
var gearVendorTypes = map[string]struct{}{
NPCTypeMerchant: {},
NPCTypeArmorer: {},
NPCTypeWeapon: {},
NPCTypeJeweler: {},
}
// IsGearVendorType is true for town NPCs that sell rolled gear rows.
func IsGearVendorType(t string) bool {
_, ok := gearVendorTypes[t]
return ok
}
// GearVendorSlots returns allowed equipment slots for this vendor type, or nil if not a gear vendor.
func GearVendorSlots(npcType string) []EquipmentSlot {
switch npcType {
case NPCTypeMerchant:
return []EquipmentSlot{SlotFeet, SlotHands, SlotCloak}
case NPCTypeArmorer:
return []EquipmentSlot{SlotLegs, SlotWrist, SlotChest, SlotHead}
case NPCTypeWeapon:
return []EquipmentSlot{SlotMainHand}
case NPCTypeJeweler:
return []EquipmentSlot{SlotFinger, SlotNeck}
default:
return nil
}
}
// IsQuestOfferNPCType is true for NPCs that can offer quest templates from the catalog.
func IsQuestOfferNPCType(t string) bool {
return t == NPCTypeBounty || t == NPCTypeElder || t == NPCTypeQuestGiver
}
// QuestTemplateAllowedForNPCType returns whether a quest template type may be offered by this NPC role.
func QuestTemplateAllowedForNPCType(npcType, questType string) bool {
switch npcType {
case NPCTypeBounty:
return questType == "kill_count" || questType == "collect_item"
case NPCTypeElder:
return questType == "visit_town" || questType == "collect_item"
case NPCTypeQuestGiver:
return true
default:
return false
}
}
// FilterQuestTemplatesByNPCType keeps only quests valid for the NPC role (no-op if not a quest NPC type).
func FilterQuestTemplatesByNPCType(quests []Quest, npcType string) []Quest {
if !IsQuestOfferNPCType(npcType) || npcType == NPCTypeQuestGiver {
return quests
}
out := make([]Quest, 0, len(quests))
for _, q := range quests {
if QuestTemplateAllowedForNPCType(npcType, q.Type) {
out = append(out, q)
}
}
return out
}

@ -0,0 +1,31 @@
package model
import "testing"
func TestFilterQuestTemplatesByNPCType(t *testing.T) {
qs := []Quest{
{ID: 1, Type: "kill_count"},
{ID: 2, Type: "visit_town"},
{ID: 3, Type: "collect_item"},
}
b := FilterQuestTemplatesByNPCType(qs, NPCTypeBounty)
if len(b) != 2 || b[0].ID != 1 || b[1].ID != 3 {
t.Fatalf("bounty filter: got %+v", b)
}
e := FilterQuestTemplatesByNPCType(qs, NPCTypeElder)
if len(e) != 2 || e[0].ID != 2 || e[1].ID != 3 {
t.Fatalf("elder filter: got %+v", e)
}
if len(FilterQuestTemplatesByNPCType(qs, NPCTypeQuestGiver)) != 3 {
t.Fatal("legacy quest_giver should not filter")
}
}
func TestGearVendorSlots(t *testing.T) {
if len(GearVendorSlots(NPCTypeWeapon)) != 1 || GearVendorSlots(NPCTypeWeapon)[0] != SlotMainHand {
t.Fatal("weapon vendor slots")
}
if !IsGearVendorType(NPCTypeJeweler) || IsGearVendorType(NPCTypeHealer) {
t.Fatal("IsGearVendorType")
}
}

@ -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"`

@ -659,8 +659,9 @@ func (s *HeroStore) ClearWsDisconnectedAt(ctx context.Context, heroID int64) err
func (s *HeroStore) ListHeroesForEngineBootstrap(ctx context.Context) ([]*model.Hero, error) { func (s *HeroStore) ListHeroesForEngineBootstrap(ctx context.Context) ([]*model.Hero, error) {
query := heroSelectQuery + ` query := heroSelectQuery + `
WHERE h.hp > 0 AND h.ws_disconnected_at IS NOT NULL WHERE h.ws_disconnected_at IS NOT NULL
ORDER BY h.updated_at ASC ORDER BY id ASC
LIMIT 100
` `
rows, err := s.pool.Query(ctx, query) rows, err := s.pool.Query(ctx, query)

@ -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

@ -0,0 +1,83 @@
-- Expand npcs.type and town_buildings.building_type; split mixed quest_givers; rotate merchants into armorer/weapon/jeweler.
ALTER TABLE public.npcs DROP CONSTRAINT IF EXISTS npcs_type_check;
ALTER TABLE public.town_buildings DROP CONSTRAINT IF EXISTS town_buildings_building_type_check;
-- visit_town + kill_count on same NPC → new elder NPC owns visit_town quests only
DO $$
DECLARE
r RECORD;
new_bid bigint;
new_nid bigint;
BEGIN
FOR r IN
SELECT n.id AS nid, n.town_id, n.offset_x AS ox, n.offset_y AS oy
FROM public.npcs n
WHERE n.type = 'quest_giver'
AND EXISTS (SELECT 1 FROM public.quests q WHERE q.npc_id = n.id AND q.type = 'visit_town')
AND EXISTS (SELECT 1 FROM public.quests q WHERE q.npc_id = n.id AND q.type = 'kill_count')
LOOP
INSERT INTO public.town_buildings (town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h, created_at)
VALUES (r.town_id, 'house.elder', r.ox + 11, r.oy + 0.5, 'south', 2.5, 2, now())
RETURNING id INTO new_bid;
INSERT INTO public.npcs (town_id, name, name_key, type, offset_x, offset_y, created_at, building_id)
VALUES (r.town_id, 'Town Speaker', 'npc.town_speaker_generic.v1', 'elder', r.ox + 11, r.oy + 1.6, now(), new_bid)
RETURNING id INTO new_nid;
UPDATE public.quests SET npc_id = new_nid WHERE npc_id = r.nid AND type = 'visit_town';
END LOOP;
END $$;
UPDATE public.npcs SET type = 'elder'
WHERE type = 'quest_giver'
AND EXISTS (SELECT 1 FROM public.quests q WHERE q.npc_id = npcs.id AND q.type = 'visit_town')
AND NOT EXISTS (SELECT 1 FROM public.quests q WHERE q.npc_id = npcs.id AND q.type = 'kill_count');
UPDATE public.npcs SET type = 'bounty_hunter' WHERE type = 'quest_giver';
WITH ranked AS (
SELECT id,
ROW_NUMBER() OVER (PARTITION BY town_id ORDER BY id) AS rn
FROM public.npcs
WHERE type = 'merchant'
)
UPDATE public.npcs n
SET type = CASE ((r.rn - 1) % 4)
WHEN 0 THEN 'merchant'
WHEN 1 THEN 'armorer'
WHEN 2 THEN 'weapon'
ELSE 'jeweler'
END
FROM ranked r
WHERE n.id = r.id;
UPDATE public.town_buildings b
SET building_type = CASE n.type
WHEN 'merchant' THEN 'house.merchant'
WHEN 'armorer' THEN 'house.armorer'
WHEN 'weapon' THEN 'house.weapon_smith'
WHEN 'jeweler' THEN 'house.jeweler'
WHEN 'bounty_hunter' THEN 'house.bounty_hunter'
WHEN 'elder' THEN 'house.elder'
ELSE b.building_type
END
FROM public.npcs n
WHERE n.building_id = b.id
AND n.type IN ('merchant', 'armorer', 'weapon', 'jeweler', 'bounty_hunter', 'elder');
ALTER TABLE public.npcs ADD CONSTRAINT npcs_type_check CHECK (type = ANY (ARRAY[
'merchant'::text, 'armorer'::text, 'weapon'::text, 'jeweler'::text,
'bounty_hunter'::text, 'elder'::text, 'healer'::text
]));
ALTER TABLE public.town_buildings ADD CONSTRAINT town_buildings_building_type_check CHECK (building_type = ANY (ARRAY[
'house.quest_giver'::text,
'house.merchant'::text, 'house.armorer'::text, 'house.weapon_smith'::text, 'house.jeweler'::text,
'house.bounty_hunter'::text, 'house.elder'::text,
'house.healer'::text,
'decoration.well'::text, 'decoration.stall'::text, 'decoration.signpost'::text
]));
SELECT pg_catalog.setval('public.npcs_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.npcs), true);
SELECT pg_catalog.setval('public.town_buildings_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.town_buildings), true);

@ -0,0 +1,135 @@
-- Per-town NPC count target: clamp(3, 6, 3 + floor((radius-7)/4)); add healers first, then rotating gear stalls.
-- New hub town 32 (Capital) with 10 NPCs; bidirectional roads to every town 1..31.
DO $$
DECLARE
t RECORD;
need int;
have int;
new_bid bigint;
rot int := 0;
vendor_types text[] := ARRAY['merchant', 'armorer', 'weapon', 'jeweler'];
tx double precision;
ty double precision;
slot int;
BEGIN
FOR t IN SELECT id, radius FROM public.towns WHERE id BETWEEN 1 AND 31 ORDER BY id
LOOP
have := (SELECT COUNT(*)::int FROM public.npcs WHERE town_id = t.id);
need := LEAST(6, GREATEST(3, 3 + (GREATEST(0, FLOOR(t.radius)::int - 7) / 4)));
WHILE have < need LOOP
IF NOT EXISTS (SELECT 1 FROM public.npcs WHERE town_id = t.id AND type = 'healer') THEN
tx := -16 + (have * 2.5);
ty := 14;
INSERT INTO public.town_buildings (town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h, created_at)
VALUES (t.id, 'house.healer', tx, ty, 'south', 2.5, 2, now())
RETURNING id INTO new_bid;
INSERT INTO public.npcs (town_id, name, name_key, type, offset_x, offset_y, created_at, building_id)
VALUES (t.id, 'Roadside Medic', 'npc.roadside_medic_generic.v1', 'healer', tx, ty + 1.1, now(), new_bid);
ELSE
slot := (rot % 4) + 1;
tx := -16 + (have * 2.8);
ty := 16;
INSERT INTO public.town_buildings (town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h, created_at)
VALUES (t.id,
CASE slot
WHEN 1 THEN 'house.merchant'
WHEN 2 THEN 'house.armorer'
WHEN 3 THEN 'house.weapon_smith'
ELSE 'house.jeweler'
END,
tx, ty, 'south', 2.5, 2, now())
RETURNING id INTO new_bid;
INSERT INTO public.npcs (town_id, name, name_key, type, offset_x, offset_y, created_at, building_id)
VALUES (t.id,
'Stall Hand ' || have::text,
'npc.stall_vendor_generic.v1',
vendor_types[slot],
tx, ty + 1.2, now(), new_bid);
rot := rot + 1;
END IF;
have := have + 1;
END LOOP;
END LOOP;
END $$;
INSERT INTO public.towns (id, name, name_key, biome, world_x, world_y, radius, level_min, level_max, created_at)
SELECT
32,
'Capital',
'town.capital.v1',
'meadow',
s.mx + 22000,
s.my,
26,
1,
60,
now()
FROM (SELECT MAX(world_x) AS mx, AVG(world_y) AS my FROM public.towns WHERE id BETWEEN 1 AND 31) AS s;
DO $$
DECLARE
i int;
bid bigint;
v_types text[] := ARRAY['merchant','armorer','weapon','jeweler','bounty_hunter','bounty_hunter','elder','healer','armorer','jeweler'];
v_bld text[] := ARRAY['house.merchant','house.armorer','house.weapon_smith','house.jeweler','house.bounty_hunter','house.bounty_hunter','house.elder','house.healer','house.armorer','house.jeweler'];
v_ox double precision[] := ARRAY[-12::double precision,-4,4,12,-12,-4,4,12,-8,8];
v_oy double precision[] := ARRAY[-8::double precision,-8,-8,-8,0,0,0,0,8,8];
v_name text;
v_key text;
BEGIN
FOR i IN 1..10 LOOP
INSERT INTO public.town_buildings (town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h, created_at)
VALUES (32, v_bld[i], v_ox[i], v_oy[i], 'south', 2.5, 2, now())
RETURNING id INTO bid;
v_name := CASE v_types[i]
WHEN 'merchant' THEN 'Emporium Clerk'
WHEN 'armorer' THEN 'Royal Armorer'
WHEN 'weapon' THEN 'Arena Smith'
WHEN 'jeweler' THEN 'Crown Jeweler'
WHEN 'bounty_hunter' THEN 'Contract Agent'
WHEN 'elder' THEN 'Seneschal'
WHEN 'healer' THEN 'Cathedral Medic'
ELSE 'Hall Attendant'
END;
v_key := CASE i
WHEN 1 THEN 'npc.capital.merchant_clerk.v1'
WHEN 2 THEN 'npc.capital.armorer.v1'
WHEN 3 THEN 'npc.capital.smith.v1'
WHEN 4 THEN 'npc.capital.jeweler.v1'
WHEN 5 THEN 'npc.capital.bounty_agent_a.v1'
WHEN 6 THEN 'npc.capital.bounty_agent_b.v1'
WHEN 7 THEN 'npc.capital.elder.v1'
WHEN 8 THEN 'npc.capital.healer.v1'
WHEN 9 THEN 'npc.capital.second_armorer.v1'
ELSE 'npc.capital.second_jeweler.v1'
END;
INSERT INTO public.npcs (town_id, name, name_key, type, offset_x, offset_y, created_at, building_id)
VALUES (32, v_name, v_key, v_types[i], v_ox[i], v_oy[i] + 1.2, now(), bid);
END LOOP;
END $$;
DO $$
DECLARE
i int;
rid bigint;
BEGIN
SELECT COALESCE(MAX(id), 0) INTO rid FROM public.roads;
FOR i IN 1..31 LOOP
rid := rid + 1;
INSERT INTO public.roads (id, from_town_id, to_town_id, distance) VALUES (rid, 32, i, 1000);
rid := rid + 1;
INSERT INTO public.roads (id, from_town_id, to_town_id, distance) VALUES (rid, i, 32, 1000);
END LOOP;
END $$;
SELECT pg_catalog.setval('public.towns_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.towns), true);
SELECT pg_catalog.setval('public.town_buildings_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.town_buildings), true);
SELECT pg_catalog.setval('public.npcs_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.npcs), true);
SELECT pg_catalog.setval('public.roads_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.roads), true);

@ -0,0 +1,180 @@
-- Medieval-style personal names for NPCs; generic elders/medics/stalls use per-id keys and pools (frontend/npcGeneratedNames.ts).
DO $$
DECLARE
elder_names text[] := ARRAY['Edmund Weaver','Roger Thane','Aldous Pryor','Wilfred Cantor','Benedict Marsh','Godwin Alder','Piers Roper','Simon Hext','Thaddeus Wexford','Lawrence Fitzhugh','Martin Crier','Humphrey Stowe','Geoffrey Merton','Richard Plowman','Walter Burgh','Thomas Reeve','Henry Wainwright','Stephen Tiler','Nicholas Cooper','James Fletcher']::text[];
medic_names text[] := ARRAY['Brother Anselm','Sister Gode','Brother Piers','Sister Edith','Brother Osmund','Sister Maud','Brother Cuthbert','Sister Agnes','Brother Wulfstan','Sister Hilde','Brother Leofric','Sister Elfrida','Brother Dunstan','Sister Godiva','Brother Aldwin','Sister Isolde','Brother Bertram','Sister Yvette','Brother Everard','Sister Matilda','Brother Hugh','Sister Beatrice','Brother Ralph','Sister Joan','Brother Miles','Sister Margery','Brother Guy','Sister Cecily','Brother Odo','Sister Ethelreda','Brother Fulke','Sister Rosamund','Brother Ivo','Sister Aveline','Brother Lambert','Sister Sybil','Brother Gerard','Sister Petronilla','Brother Thurstan','Sister Hawise']::text[];
stall_names text[] := ARRAY['Henric Cotlar','Giles Turner','Ralf Cordwainer','Drogo Mercer','Ivo Chapman','Baldwin Fuller','Reynard Webber','Sigeric Dyer','Ailwin Skinner','Leofwine Bowyer','Ordgar Fletcher','Wulfhere Smith','Eadric Mason','Cynric Thatcher','Beorn Carver','Grimwald Cooper','Sæward Potter','Tovi Weaver','Ketil Wright','Orm Gardiner','Hakon Fisher','Snorri Cook','Ulf Baker','Eirik Brewer','Halfdan Butcher','Ragnar Chandler','Sweyn Saddler','Toki Horner','Grim Kelner','Arnulf Spicer','Berenger Glover','Fulk Haberdasher','Payn Cutler','Jocelin Nailor','Eluard Whittler','Gervase Joiner','Hamo Sawyer','Isembard Planer','Lancelin Turner','Mainard Wheeler','Odo Carter','Pagan Porter','Quentin Badger','Roric Packer','Savin Binder','Turold Tenter','Ulric Shearer','Warin Fuller','Yvain Mercer','Zacharias Draper','Alured Hosier','Brien Leatherseller','Conan Fellmonger','Denzil Woolman','Elwin Silkman','Faramund Linendraper','Garin Mercer','Helias Chapman','Isembart Cordwainer','Jordan Webber','Kenelm Dyer','Laurin Skinner','Milo Bowyer','Nigel Fletcher','Osmund Smith','Percy Mason','Quince Thatcher','Roland Carver','Sayer Cooper','Turgis Potter','Urian Weaver','Virgil Wright','Wymar Gardiner','York Fisher','Zeno Cook','Alaric Baker','Brice Brewer','Crispin Butcher','Drust Chandler','Emeric Saddler','Faramir Horner','Gawain Kelner','Hadwin Spicer','Idris Glover','Jasper Haberdasher','Kenrick Cutler','Lionel Nailor','Merrick Whittler','Nestor Joiner','Owyn Sawyer','Piers Planer','Quinlan Turner','Roric Wheeler','Seward Carter','Tancred Porter','Ulfric Badger','Valens Packer','Wulfhere Binder','Yngvar Tenter','Zebulon Shearer','Athelstan Fuller','Baldric Mercer','Cerdic Chapman','Dunstan Cordwainer','Eadwine Webber','Frith Dyer','Godric Skinner','Hereward Bowyer','Ingulf Fletcher','Kenelm Smith','Leofric Mason','Mærwynn Thatcher']::text[];
BEGIN
UPDATE public.npcs SET
name_key = 'npc.elder.byid.' || id::text || '.v1',
name = elder_names[1 + ((id * 3) % 20)]
WHERE name_key = 'npc.town_speaker_generic.v1';
UPDATE public.npcs SET
name_key = 'npc.medic.byid.' || id::text || '.v1',
name = medic_names[1 + ((id * 7) % 40)]
WHERE name_key = 'npc.roadside_medic_generic.v1';
UPDATE public.npcs SET
name_key = 'npc.stall.byid.' || id::text || '.v1',
name = stall_names[1 + ((id * 13) % 112)]
WHERE name_key = 'npc.stall_vendor_generic.v1';
END $$;
UPDATE public.npcs SET name = v.n
FROM (VALUES
('npc.capital.merchant_clerk.v1', 'Hugh Pennington'),
('npc.capital.armorer.v1', 'Raoul d''Aubigny'),
('npc.capital.smith.v1', 'Gilles Ferron'),
('npc.capital.jeweler.v1', 'Ysabel Tremaine'),
('npc.capital.bounty_agent_a.v1', 'Roderick Vaughn'),
('npc.capital.bounty_agent_b.v1', 'Matteo Fabbri'),
('npc.capital.elder.v1', 'Anselm Corwyn'),
('npc.capital.healer.v1', 'Clothilde Mercier'),
('npc.capital.second_armorer.v1', 'Bertrand Hale'),
('npc.capital.second_jeweler.v1', 'Eleonore Rivard')
) AS v(k, n)
WHERE public.npcs.name_key = v.k;
UPDATE public.npcs SET name = CASE id
WHEN 1 THEN 'Maren Thistlewood'
WHEN 2 THEN 'Finn Marlow'
WHEN 3 THEN 'Asha Kentwell'
WHEN 4 THEN 'Halric Morrow'
WHEN 5 THEN 'Wynn Cartwright'
WHEN 6 THEN 'Orin Aldgate'
WHEN 7 THEN 'Osbert Waynwood'
WHEN 8 THEN 'Liora Selwyn'
END
WHERE id BETWEEN 1 AND 8;
UPDATE public.npcs SET
name_key = CASE id
WHEN 9 THEN 'npc.brandric_thacker.v1'
WHEN 10 THEN 'npc.conrad_pitwright.v1'
WHEN 11 THEN 'npc.nessa_bramble.v1'
WHEN 12 THEN 'npc.torin_marshwick.v1'
WHEN 13 THEN 'npc.renulf_broadmere.v1'
WHEN 14 THEN 'npc.kael_ironwright.v1'
WHEN 15 THEN 'npc.edmund_cinderwell.v1'
WHEN 16 THEN 'npc.aelith_northgate.v1'
WHEN 17 THEN 'npc.dorian_hawke.v1'
WHEN 18 THEN 'npc.mariel_starling.v1'
WHEN 19 THEN 'npc.milo_ropewalk.v1'
WHEN 20 THEN 'npc.lissa_harcourt.v1'
WHEN 21 THEN 'npc.jasper_kindling.v1'
WHEN 22 THEN 'npc.kess_wiley.v1'
WHEN 23 THEN 'npc.aldwin_relicton.v1'
WHEN 24 THEN 'npc.torvik_grimstad.v1'
WHEN 25 THEN 'npc.morna_fenwick.v1'
WHEN 26 THEN 'npc.morah_ellis.v1'
END,
name = CASE id
WHEN 9 THEN 'Brandric Thacker'
WHEN 10 THEN 'Conrad Pitwright'
WHEN 11 THEN 'Nessa Bramble'
WHEN 12 THEN 'Torin Marshwick'
WHEN 13 THEN 'Renulf Broadmere'
WHEN 14 THEN 'Kael Ironwright'
WHEN 15 THEN 'Edmund Cinderwell'
WHEN 16 THEN 'Aelith Northgate'
WHEN 17 THEN 'Dorian Hawke'
WHEN 18 THEN 'Mariel Starling'
WHEN 19 THEN 'Milo Ropewalk'
WHEN 20 THEN 'Lissa Harcourt'
WHEN 21 THEN 'Jasper Kindling'
WHEN 22 THEN 'Kess Wiley'
WHEN 23 THEN 'Aldwin Relicton'
WHEN 24 THEN 'Torvik Grimstad'
WHEN 25 THEN 'Morna Fenwick'
WHEN 26 THEN 'Morah Ellis'
END
WHERE id BETWEEN 9 AND 26;
UPDATE public.npcs SET name = CASE id
WHEN 27 THEN 'Sera Whitcomb'
WHEN 28 THEN 'Bram Ashcombe'
WHEN 29 THEN 'Nils Copperton'
WHEN 30 THEN 'Mara Tinwell'
WHEN 31 THEN 'Agnes Stillwater'
WHEN 32 THEN 'Rodrick Cantrell'
WHEN 33 THEN 'Wulfric Strand'
WHEN 34 THEN 'Jada Boltwright'
WHEN 35 THEN 'Alaric Motlow'
WHEN 36 THEN 'Percival Pike'
WHEN 37 THEN 'Eadric Ashenford'
WHEN 38 THEN 'Yoric Scarn'
WHEN 39 THEN 'Rillian Hereward'
WHEN 40 THEN 'Tove Millerson'
WHEN 41 THEN 'Gareth Grantham'
WHEN 42 THEN 'Renulf Sackville'
WHEN 43 THEN 'Bernard Lukin'
WHEN 44 THEN 'Aldwin Grimston'
WHEN 45 THEN 'Edmund Edgerton'
WHEN 46 THEN 'Crispin Aylesford'
WHEN 47 THEN 'Brunhild Flint'
WHEN 48 THEN 'Oren Starward'
WHEN 49 THEN 'Simon Spirewell'
WHEN 50 THEN 'Hugh Comstock'
WHEN 51 THEN 'Yves Portier'
WHEN 52 THEN 'Cedric Brinewell'
WHEN 53 THEN 'Osmund Salter'
WHEN 54 THEN 'Rhys Reedman'
WHEN 55 THEN 'Godfrey Middleton'
WHEN 56 THEN 'Wystan Postlethwaite'
WHEN 57 THEN 'Ivo Ironside'
WHEN 58 THEN 'Roland Rivett'
WHEN 59 THEN 'Lucan Forrest'
WHEN 60 THEN 'Alaric Boghurst'
WHEN 61 THEN 'Norbert Fenwick'
WHEN 62 THEN 'Miles Myreham'
WHEN 63 THEN 'Cuthbert Reed'
WHEN 64 THEN 'Wendel Marsham'
WHEN 65 THEN 'Sigurd Dunstan'
WHEN 66 THEN 'Silas Siltwell'
WHEN 67 THEN 'Peter Sanderson'
WHEN 68 THEN 'Griselda Holt'
WHEN 69 THEN 'Bartholomew Howe'
WHEN 70 THEN 'Baldwin Bonewright'
WHEN 71 THEN 'Cole Aldridge'
WHEN 72 THEN 'Shadrach Morrow'
WHEN 73 THEN 'Rowan Mistwell'
WHEN 74 THEN 'Fergus Fogarty'
WHEN 75 THEN 'Dewi Tarrant'
WHEN 76 THEN 'Vespasian Vale'
WHEN 77 THEN 'Hugo Holloway'
WHEN 78 THEN 'Meredith Stowe'
WHEN 79 THEN 'Roderick Rotherham'
WHEN 80 THEN 'Beatrice Boghurst'
WHEN 81 THEN 'Ashford Hale'
WHEN 82 THEN 'Cyril Cinders'
WHEN 83 THEN 'Emrys Emberly'
WHEN 84 THEN 'Alicia Ashford'
WHEN 85 THEN 'Thorne Hawthorn'
WHEN 86 THEN 'Brian Briarton'
WHEN 87 THEN 'Rowan Rootwell'
WHEN 88 THEN 'Leofric Leaford'
WHEN 89 THEN 'Galfrid Gales'
WHEN 90 THEN 'Wynstan Windham'
WHEN 91 THEN 'Gustav Merseburg'
WHEN 92 THEN 'Blaise Brissot'
WHEN 93 THEN 'Archibald Frostwick'
WHEN 94 THEN 'Rhys Rimer'
WHEN 95 THEN 'Horace Hoarwell'
WHEN 96 THEN 'Isolde Ismay'
WHEN 97 THEN 'Solomon Sunderland'
WHEN 98 THEN 'Clifford Cliffeton'
WHEN 99 THEN 'Craig Cragwell'
WHEN 100 THEN 'Dustin Harwell'
WHEN 101 THEN 'Marshall Fordham'
WHEN 102 THEN 'Rivers Trent'
WHEN 103 THEN 'Bridges Ballard'
WHEN 104 THEN 'Sterling Brook'
WHEN 105 THEN 'Sevrin Veilcourt'
WHEN 106 THEN 'Sterling Starwell'
WHEN 107 THEN 'Neville Nevett'
WHEN 108 THEN 'Vera Veilhart'
END
WHERE id BETWEEN 27 AND 108;

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

@ -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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

@ -0,0 +1,428 @@
# 🎮 Sprite & Tileset Implementation Guide (Vertical Slice)
## 1. Scope
This document defines **technical requirements and pipeline rules** for using generated sprites, tilesets, and VFX in the project.
Goal: enable a developer to **import, slice, and assemble a fully playable vertical slice** without ambiguity.
---
## 2. Global Standards
```txt
Camera: Top-down (3/4)
Tile Size: 64x64 px
Internal Grid: 16x16 px
Padding: 2 px
Format: PNG (RGBA)
Filter Mode: Point (no filtering)
Compression: None
Pixels Per Unit: 64
```
---
## 3. Project Structure
```txt
/assets
/tiles
/props
/buildings
/characters
/enemies
/items
/vfx
/ui
```
---
## 4. Tileset Implementation
### 4.1 Import Settings
* Sprite Mode: Multiple
* Mesh Type: Full Rect
* Filter: Point
* Compression: None
---
### 4.2 Grid Slicing
```txt
Cell Size: 64x64
Offset: 0,0
Padding: 2px (if present in atlas)
Pivot: Center (0.5, 0.5)
```
---
### 4.3 Tile Categories
#### Ground Layer (collision + navigation)
```txt
road_center
road_variation_01..03
road_edge_N/E/S/W
road_corner_outer
road_corner_inner
```
#### Overlay Layer (no collision)
```txt
overlay_corruption
overlay_cracks
overlay_grass
```
---
### 4.4 Tilemap Layers
```txt
Layer 0: Ground
Layer 1: Overlay
Layer 2: Props (optional tile-based)
```
---
### 4.5 Autotile Requirements
Each terrain type must include:
```txt
1 center
4 edges
4 outer corners
4 inner corners
3+ variations
```
---
## 5. Props Implementation
### 5.1 Import
* Sprite Mode: Single
* Pivot: Bottom-Center (0.5, 0.0)
---
### 5.2 Rules
```txt
Height: 0.51.5 tile
Scale: consistent with tile grid
Collider: BoxCollider (manual)
```
---
## 6. Character (Hero)
### 6.1 Import Settings
```txt
Sprite Mode: Multiple
Cell Size: 64x64
Pivot: Bottom-Center (0.5, 0.0)
```
---
### 6.2 Animation Layout
```txt
Row = animation
Column = frame
Direction = left → right
```
---
### 6.3 Required Animations
```txt
idle: 6 frames
walk: 8 frames
attack: 68 frames
hit: 4 frames
death: 6 frames
```
---
### 6.4 Runtime Setup
* Animator Controller required
* State machine:
* Idle ↔ Walk
* Walk → Attack
* Any → Hit
* Any → Death
---
### 6.5 Collision
```txt
Collider: Capsule
Hitbox: smaller than sprite
Weapon hitbox: separate trigger collider
```
---
## 7. Enemies
### 7.1 Parameters
```txt
Size: 96128 px
Pivot: Bottom-Center
```
---
### 7.2 Animations
```txt
idle
move
attack
hit
death
```
---
### 7.3 Gameplay Rules
* Weak points must be visually highlighted
* Hitbox must NOT match full sprite size
---
## 8. Items (Loot)
### 8.1 Import
```txt
Size: 32x32
Pivot: Center (0.5, 0.5)
```
---
### 8.2 Categories
```txt
consumables
currency
equipment
keys
```
---
### 8.3 Visual Encoding
```txt
common = neutral
rare = blue
epic = purple
```
---
## 9. VFX
### 9.1 Import
```txt
Size: 64128 px
Pivot: Center
Material: Additive / Alpha Blend
```
---
### 9.2 Types
```txt
slash
impact
portal
particles
electricity
```
---
### 9.3 Rules
* VFX must be separate from characters
* Use flipbook animation where applicable
* Do NOT bake effects into base sprites
---
## 10. UI
### 10.1 Sizes
```txt
Icons: 32x32
Slots: 64x64
Panels: flexible
```
---
### 10.2 Rules
* Icons must be readable without text
* Glow indicates interactivity
---
## 11. Lighting Model
### 11.1 Separation
```txt
Base sprite = no heavy lighting
Emissive = separate (windows, crystals)
Lighting = runtime (engine)
```
---
### 11.2 Restrictions
```txt
DO NOT bake global lighting into tiles
DO NOT mix emissive with base diffuse
```
---
## 12. Naming Convention
```txt
tiles/road_center_01.png
tiles/road_edge_n_01.png
props/lamp_01.png
characters/hero_idle_01.png
characters/hero_attack_03.png
enemies/corrupt_beast_idle_01.png
items/potion_red.png
vfx/slash_01.png
ui/icon_attack.png
```
---
## 13. Engine Setup (Unity / Godot)
### 13.1 Import Settings
```txt
Filter Mode = Point
Compression = None
Pixels Per Unit = 64
```
---
### 13.2 Sprite Mode
```txt
Tilesets → Multiple
Props/Items → Single
Characters → Multiple
```
---
## 14. Collision System
```txt
Tilemap Collider → Ground
Box Collider → Props
Capsule Collider → Characters
Trigger Collider → Attacks / Loot
```
---
## 15. Render Order
```txt
1 Ground
2 Overlay
3 Props
4 Characters
5 VFX
6 UI
```
---
## 16. Gameplay Loop (Vertical Slice)
```txt
1. Player movement (tilemap navigation)
2. Enter corruption zone
3. Enemy aggro
4. Combat (melee + VFX)
5. Loot drop
6. Loot collection
7. Exit via portal
```
---
## 17. Critical Constraints
```txt
All assets must align to 64x64 grid
All sprites must have correct pivot
No mixed pixel density
No baked lighting abuse
Tiles ≠ Props (strict separation)
Minimum 3 variations per tile type
```
---
## 18. Acceptance Criteria
Implementation is valid if:
* Tilemap builds without seams
* Character animations play correctly
* Combat loop is functional
* Loot is collectible and readable
* Portal completes loop
* No visual scale inconsistencies
---
**End of document**

@ -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',

@ -0,0 +1,210 @@
import type { Locale } from './localeCodes';
type Bilingual = { en: string; ru: string };
/** Pool for `npc.elder.byid.<id>.v1` — index `(id * 3) % length` (matches migration 000034). */
export const ELDER_GEN_NAMES: Bilingual[] = [
{ en: 'Edmund Weaver', ru: 'Эдмунд Уивер' },
{ en: 'Roger Thane', ru: 'Роджер Тейн' },
{ en: 'Aldous Pryor', ru: 'Олдос Прайор' },
{ en: 'Wilfred Cantor', ru: 'Уилфред Кантор' },
{ en: 'Benedict Marsh', ru: 'Бенедикт Марш' },
{ en: 'Godwin Alder', ru: 'Годвин Олдер' },
{ en: 'Piers Roper', ru: 'Пирс Ропер' },
{ en: 'Simon Hext', ru: 'Саймон Хекст' },
{ en: 'Thaddeus Wexford', ru: 'Таддеус Уэксфорд' },
{ en: 'Lawrence Fitzhugh', ru: 'Лоуренс Фитцхью' },
{ en: 'Martin Crier', ru: 'Мартин Крайер' },
{ en: 'Humphrey Stowe', ru: 'Хамфри Стоу' },
{ en: 'Geoffrey Merton', ru: 'Джеффри Мертон' },
{ en: 'Richard Plowman', ru: 'Ричард Плауман' },
{ en: 'Walter Burgh', ru: 'Уолтер Бург' },
{ en: 'Thomas Reeve', ru: 'Томас Рив' },
{ en: 'Henry Wainwright', ru: 'Генри Уайнрайт' },
{ en: 'Stephen Tiler', ru: 'Стивен Тайлер' },
{ en: 'Nicholas Cooper', ru: 'Николас Купер' },
{ en: 'James Fletcher', ru: 'Джеймс Флетчер' },
];
/** Pool for `npc.medic.byid.<id>.v1` — index `(id * 7) % length`. */
export const MEDIC_GEN_NAMES: Bilingual[] = [
{ en: 'Brother Anselm', ru: 'Брат Ансельм' },
{ en: 'Sister Gode', ru: 'Сестра Годе' },
{ en: 'Brother Piers', ru: 'Брат Пирс' },
{ en: 'Sister Edith', ru: 'Сестра Эдит' },
{ en: 'Brother Osmund', ru: 'Брат Осмунд' },
{ en: 'Sister Maud', ru: 'Сестра Мод' },
{ en: 'Brother Cuthbert', ru: 'Брат Кутберт' },
{ en: 'Sister Agnes', ru: 'Сестра Агнес' },
{ en: 'Brother Wulfstan', ru: 'Брат Вульфстан' },
{ en: 'Sister Hilde', ru: 'Сестра Хильде' },
{ en: 'Brother Leofric', ru: 'Брат Леофрик' },
{ en: 'Sister Elfrida', ru: 'Сестра Эльфрида' },
{ en: 'Brother Dunstan', ru: 'Брат Дунстан' },
{ en: 'Sister Godiva', ru: 'Сестра Годива' },
{ en: 'Brother Aldwin', ru: 'Брат Олдвин' },
{ en: 'Sister Isolde', ru: 'Сестра Изольда' },
{ en: 'Brother Bertram', ru: 'Брат Бертрам' },
{ en: 'Sister Yvette', ru: 'Сестра Иветт' },
{ en: 'Brother Everard', ru: 'Брат Эверард' },
{ en: 'Sister Matilda', ru: 'Сестра Матильда' },
{ en: 'Brother Hugh', ru: 'Брат Хью' },
{ en: 'Sister Beatrice', ru: 'Сестра Беатрис' },
{ en: 'Brother Ralph', ru: 'Брат Ральф' },
{ en: 'Sister Joan', ru: 'Сестра Джоан' },
{ en: 'Brother Miles', ru: 'Брат Майлз' },
{ en: 'Sister Margery', ru: 'Сестра Маргери' },
{ en: 'Brother Guy', ru: 'Брат Гай' },
{ en: 'Sister Cecily', ru: 'Сестра Сесили' },
{ en: 'Brother Odo', ru: 'Брат Одо' },
{ en: 'Sister Ethelreda', ru: 'Сестра Этельреда' },
{ en: 'Brother Fulke', ru: 'Брат Фулк' },
{ en: 'Sister Rosamund', ru: 'Сестра Розамунд' },
{ en: 'Brother Ivo', ru: 'Брат Иво' },
{ en: 'Sister Aveline', ru: 'Сестра Авелин' },
{ en: 'Brother Lambert', ru: 'Брат Ламберт' },
{ en: 'Sister Sybil', ru: 'Сестра Сибил' },
{ en: 'Brother Gerard', ru: 'Брат Джерард' },
{ en: 'Sister Petronilla', ru: 'Сестра Петронилла' },
{ en: 'Brother Thurstan', ru: 'Брат Терстан' },
{ en: 'Sister Hawise', ru: 'Сестра Хавис' },
];
/** Pool for `npc.stall.byid.<id>.v1` — index `(id * 13) % length`. */
export const STALL_GEN_NAMES: Bilingual[] = [
{ en: 'Henric Cotlar', ru: 'Хенрик Котлар' },
{ en: 'Giles Turner', ru: 'Джайлз Тёрнер' },
{ en: 'Ralf Cordwainer', ru: 'Ральф Кордуайнер' },
{ en: 'Drogo Mercer', ru: 'Дрого Мерсер' },
{ en: 'Ivo Chapman', ru: 'Иво Чепмен' },
{ en: 'Baldwin Fuller', ru: 'Болдуин Фуллер' },
{ en: 'Reynard Webber', ru: 'Рейнард Веббер' },
{ en: 'Sigeric Dyer', ru: 'Сигерик Дайер' },
{ en: 'Ailwin Skinner', ru: 'Эйлвин Скиннер' },
{ en: 'Leofwine Bowyer', ru: 'Леофвин Бойер' },
{ en: 'Ordgar Fletcher', ru: 'Ордгар Флетчер' },
{ en: 'Wulfhere Smith', ru: 'Вульфхере Смит' },
{ en: 'Eadric Mason', ru: 'Эадрик Мейсон' },
{ en: 'Cynric Thatcher', ru: 'Кинрик Тэтчер' },
{ en: 'Beorn Carver', ru: 'Беорн Карвер' },
{ en: 'Grimwald Cooper', ru: 'Гримвальд Купер' },
{ en: 'Sæward Potter', ru: 'Севард Поттер' },
{ en: 'Tovi Weaver', ru: 'Тови Уивер' },
{ en: 'Ketil Wright', ru: 'Кетил Райт' },
{ en: 'Orm Gardiner', ru: 'Орм Гарднер' },
{ en: 'Hakon Fisher', ru: 'Хакон Фишер' },
{ en: 'Snorri Cook', ru: 'Снорри Кук' },
{ en: 'Ulf Baker', ru: 'Ульф Бейкер' },
{ en: 'Eirik Brewer', ru: 'Эйрик Брюэр' },
{ en: 'Halfdan Butcher', ru: 'Халфдан Бутчер' },
{ en: 'Ragnar Chandler', ru: 'Рагнар Чендлер' },
{ en: 'Sweyn Saddler', ru: 'Свейн Седдлер' },
{ en: 'Toki Horner', ru: 'Токи Хорнер' },
{ en: 'Grim Kelner', ru: 'Грим Келнер' },
{ en: 'Arnulf Spicer', ru: 'Арнульф Спайсер' },
{ en: 'Berenger Glover', ru: 'Беренгер Гловер' },
{ en: 'Fulk Haberdasher', ru: 'Фулк Хабердэшер' },
{ en: 'Payn Cutler', ru: 'Пейн Катлер' },
{ en: 'Jocelin Nailor', ru: 'Джоселин Нейлор' },
{ en: 'Eluard Whittler', ru: 'Элуард Виттлер' },
{ en: 'Gervase Joiner', ru: 'Джервейс Джойнер' },
{ en: 'Hamo Sawyer', ru: 'Хамо Сойер' },
{ en: 'Isembard Planer', ru: 'Изембард Планер' },
{ en: 'Lancelin Turner', ru: 'Ланселин Тёрнер' },
{ en: 'Mainard Wheeler', ru: 'Майнард Уилер' },
{ en: 'Odo Carter', ru: 'Одо Картер' },
{ en: 'Pagan Porter', ru: 'Пэган Портер' },
{ en: 'Quentin Badger', ru: 'Квентин Баджер' },
{ en: 'Roric Packer', ru: 'Рорик Пакер' },
{ en: 'Savin Binder', ru: 'Савин Байндер' },
{ en: 'Turold Tenter', ru: 'Турольд Тентер' },
{ en: 'Ulric Shearer', ru: 'Ульрик Ширер' },
{ en: 'Warin Fuller', ru: 'Варин Фуллер' },
{ en: 'Yvain Mercer', ru: 'Ивейн Мерсер' },
{ en: 'Zacharias Draper', ru: 'Захария Дрейпер' },
{ en: 'Alured Hosier', ru: 'Альюред Хозиер' },
{ en: 'Brien Leatherseller', ru: 'Бриен Лезерселлер' },
{ en: 'Conan Fellmonger', ru: 'Конан Феллмонгер' },
{ en: 'Denzil Woolman', ru: 'Дензил Вулман' },
{ en: 'Elwin Silkman', ru: 'Элвин Силкман' },
{ en: 'Faramund Linendraper', ru: 'Фарамунд Линендрейпер' },
{ en: 'Garin Mercer', ru: 'Гарин Мерсер' },
{ en: 'Helias Chapman', ru: 'Хелиас Чепмен' },
{ en: 'Isembart Cordwainer', ru: 'Изембарт Кордуайнер' },
{ en: 'Jordan Webber', ru: 'Джордан Веббер' },
{ en: 'Kenelm Dyer', ru: 'Кенелм Дайер' },
{ en: 'Laurin Skinner', ru: 'Лаурин Скиннер' },
{ en: 'Milo Bowyer', ru: 'Мило Бойер' },
{ en: 'Nigel Fletcher', ru: 'Найджел Флетчер' },
{ en: 'Osmund Smith', ru: 'Осмунд Смит' },
{ en: 'Percy Mason', ru: 'Перси Мейсон' },
{ en: 'Quince Thatcher', ru: 'Квинс Тэтчер' },
{ en: 'Roland Carver', ru: 'Роланд Карвер' },
{ en: 'Sayer Cooper', ru: 'Сайер Купер' },
{ en: 'Turgis Potter', ru: 'Тургис Поттер' },
{ en: 'Urian Weaver', ru: 'Уриан Уивер' },
{ en: 'Virgil Wright', ru: 'Вергил Райт' },
{ en: 'Wymar Gardiner', ru: 'Вимар Гарднер' },
{ en: 'York Fisher', ru: 'Йорк Фишер' },
{ en: 'Zeno Cook', ru: 'Зено Кук' },
{ en: 'Alaric Baker', ru: 'Аларик Бейкер' },
{ en: 'Brice Brewer', ru: 'Брайс Брюэр' },
{ en: 'Crispin Butcher', ru: 'Криспин Бутчер' },
{ en: 'Drust Chandler', ru: 'Друст Чендлер' },
{ en: 'Emeric Saddler', ru: 'Эмерик Седдлер' },
{ en: 'Faramir Horner', ru: 'Фарамир Хорнер' },
{ en: 'Gawain Kelner', ru: 'Гавейн Келнер' },
{ en: 'Hadwin Spicer', ru: 'Хадвин Спайсер' },
{ en: 'Idris Glover', ru: 'Идрис Гловер' },
{ en: 'Jasper Haberdasher', ru: 'Джаспер Хабердэшер' },
{ en: 'Kenrick Cutler', ru: 'Кенрик Катлер' },
{ en: 'Lionel Nailor', ru: 'Лайонел Нейлор' },
{ en: 'Merrick Whittler', ru: 'Меррик Виттлер' },
{ en: 'Nestor Joiner', ru: 'Нестор Джойнер' },
{ en: 'Owyn Sawyer', ru: 'Оуин Сойер' },
{ en: 'Piers Planer', ru: 'Пирс Планер' },
{ en: 'Quinlan Turner', ru: 'Квинлан Тёрнер' },
{ en: 'Roric Wheeler', ru: 'Рорик Уилер' },
{ en: 'Seward Carter', ru: 'Сьюард Картер' },
{ en: 'Tancred Porter', ru: 'Танкред Портер' },
{ en: 'Ulfric Badger', ru: 'Ульфрик Баджер' },
{ en: 'Valens Packer', ru: 'Валенс Пакер' },
{ en: 'Wulfhere Binder', ru: 'Вульфхере Байндер' },
{ en: 'Yngvar Tenter', ru: 'Ингвар Тентер' },
{ en: 'Zebulon Shearer', ru: 'Зевулон Ширер' },
{ en: 'Athelstan Fuller', ru: 'Ательстан Фуллер' },
{ en: 'Baldric Mercer', ru: 'Балдрик Мерсер' },
{ en: 'Cerdic Chapman', ru: 'Сердик Чепмен' },
{ en: 'Dunstan Cordwainer', ru: 'Дунстан Кордуайнер' },
{ en: 'Eadwine Webber', ru: 'Эадвин Веббер' },
{ en: 'Frith Dyer', ru: 'Фрит Дайер' },
{ en: 'Godric Skinner', ru: 'Годрик Скиннер' },
{ en: 'Hereward Bowyer', ru: 'Херуорд Бойер' },
{ en: 'Ingulf Fletcher', ru: 'Ингульф Флетчер' },
{ en: 'Kenelm Smith', ru: 'Кенелм Смит' },
{ en: 'Leofric Mason', ru: 'Леофрик Мейсон' },
{ en: 'Mærwynn Thatcher', ru: 'Мервинн Тэтчер' },
];
export function npcByIdKeyLabel(locale: Locale, key: string | undefined): string | undefined {
if (!key) return undefined;
let m = /^npc\.elder\.byid\.(\d+)\.v1$/.exec(key);
if (m?.[1]) {
const id = parseInt(m[1], 10);
const row = ELDER_GEN_NAMES[(id * 3) % ELDER_GEN_NAMES.length]!;
return locale === 'ru' ? row.ru : row.en;
}
m = /^npc\.medic\.byid\.(\d+)\.v1$/.exec(key);
if (m?.[1]) {
const id = parseInt(m[1], 10);
const row = MEDIC_GEN_NAMES[(id * 7) % MEDIC_GEN_NAMES.length]!;
return locale === 'ru' ? row.ru : row.en;
}
m = /^npc\.stall\.byid\.(\d+)\.v1$/.exec(key);
if (m?.[1]) {
const id = parseInt(m[1], 10);
const row = STALL_GEN_NAMES[(id * 13) % STALL_GEN_NAMES.length]!;
return locale === 'ru' ? row.ru : row.en;
}
return undefined;
}

@ -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}

@ -0,0 +1,5 @@
-- Generated by scripts/gen-npc-sql-arrays.mjs from npcGeneratedNames.ts
-- lengths: elder=20 medic=40 stall=112
elder_names text[] := ARRAY['Edmund Weaver','Roger Thane','Aldous Pryor','Wilfred Cantor','Benedict Marsh','Godwin Alder','Piers Roper','Simon Hext','Thaddeus Wexford','Lawrence Fitzhugh','Martin Crier','Humphrey Stowe','Geoffrey Merton','Richard Plowman','Walter Burgh','Thomas Reeve','Henry Wainwright','Stephen Tiler','Nicholas Cooper','James Fletcher']::text[];
medic_names text[] := ARRAY['Brother Anselm','Sister Gode','Brother Piers','Sister Edith','Brother Osmund','Sister Maud','Brother Cuthbert','Sister Agnes','Brother Wulfstan','Sister Hilde','Brother Leofric','Sister Elfrida','Brother Dunstan','Sister Godiva','Brother Aldwin','Sister Isolde','Brother Bertram','Sister Yvette','Brother Everard','Sister Matilda','Brother Hugh','Sister Beatrice','Brother Ralph','Sister Joan','Brother Miles','Sister Margery','Brother Guy','Sister Cecily','Brother Odo','Sister Ethelreda','Brother Fulke','Sister Rosamund','Brother Ivo','Sister Aveline','Brother Lambert','Sister Sybil','Brother Gerard','Sister Petronilla','Brother Thurstan','Sister Hawise']::text[];
stall_names text[] := ARRAY['Henric Cotlar','Giles Turner','Ralf Cordwainer','Drogo Mercer','Ivo Chapman','Baldwin Fuller','Reynard Webber','Sigeric Dyer','Ailwin Skinner','Leofwine Bowyer','Ordgar Fletcher','Wulfhere Smith','Eadric Mason','Cynric Thatcher','Beorn Carver','Grimwald Cooper','Sæward Potter','Tovi Weaver','Ketil Wright','Orm Gardiner','Hakon Fisher','Snorri Cook','Ulf Baker','Eirik Brewer','Halfdan Butcher','Ragnar Chandler','Sweyn Saddler','Toki Horner','Grim Kelner','Arnulf Spicer','Berenger Glover','Fulk Haberdasher','Payn Cutler','Jocelin Nailor','Eluard Whittler','Gervase Joiner','Hamo Sawyer','Isembard Planer','Lancelin Turner','Mainard Wheeler','Odo Carter','Pagan Porter','Quentin Badger','Roric Packer','Savin Binder','Turold Tenter','Ulric Shearer','Warin Fuller','Yvain Mercer','Zacharias Draper','Alured Hosier','Brien Leatherseller','Conan Fellmonger','Denzil Woolman','Elwin Silkman','Faramund Linendraper','Garin Mercer','Helias Chapman','Isembart Cordwainer','Jordan Webber','Kenelm Dyer','Laurin Skinner','Milo Bowyer','Nigel Fletcher','Osmund Smith','Percy Mason','Quince Thatcher','Roland Carver','Sayer Cooper','Turgis Potter','Urian Weaver','Virgil Wright','Wymar Gardiner','York Fisher','Zeno Cook','Alaric Baker','Brice Brewer','Crispin Butcher','Drust Chandler','Emeric Saddler','Faramir Horner','Gawain Kelner','Hadwin Spicer','Idris Glover','Jasper Haberdasher','Kenrick Cutler','Lionel Nailor','Merrick Whittler','Nestor Joiner','Owyn Sawyer','Piers Planer','Quinlan Turner','Roric Wheeler','Seward Carter','Tancred Porter','Ulfric Badger','Valens Packer','Wulfhere Binder','Yngvar Tenter','Zebulon Shearer','Athelstan Fuller','Baldric Mercer','Cerdic Chapman','Dunstan Cordwainer','Eadwine Webber','Frith Dyer','Godric Skinner','Hereward Bowyer','Ingulf Fletcher','Kenelm Smith','Leofric Mason','Mærwynn Thatcher']::text[];

@ -0,0 +1,42 @@
import { readFileSync, writeFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const s = readFileSync(join(root, 'frontend/src/i18n/npcGeneratedNames.ts'), 'utf8');
function extractArray(constName) {
const start = s.indexOf(`export const ${constName}`);
if (start < 0) throw new Error('missing ' + constName);
const slice = s.slice(start, start + 80000);
const out = [];
const re = /en: '((?:\\'|[^'])*)'/g;
let m;
let depth = 0;
const subStart = slice.indexOf('[');
const subEnd = slice.indexOf('];', subStart);
const block = slice.slice(subStart, subEnd);
while ((m = re.exec(block))) {
out.push(m[1].replace(/\\'/g, "'"));
}
return out;
}
const elder = extractArray('ELDER_GEN_NAMES');
const medic = extractArray('MEDIC_GEN_NAMES');
const stall = extractArray('STALL_GEN_NAMES');
function sqlArr(a) {
return `ARRAY[${a.map((x) => `'${x.replace(/'/g, "''")}'`).join(',')}]::text[]`;
}
const sql =
`-- Generated by scripts/gen-npc-sql-arrays.mjs from npcGeneratedNames.ts\n` +
`-- lengths: elder=${elder.length} medic=${medic.length} stall=${stall.length}\n` +
`elder_names text[] := ${sqlArr(elder)};\n` +
`medic_names text[] := ${sqlArr(medic)};\n` +
`stall_names text[] := ${sqlArr(stall)};\n`;
writeFileSync(join(root, 'scripts/_npc_name_arrays_snippet.sql'), sql);
console.log('Wrote scripts/_npc_name_arrays_snippet.sql', elder.length, medic.length, stall.length);

@ -0,0 +1,209 @@
import { readFileSync, writeFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const snippetPath = join(root, 'scripts/_npc_name_arrays_snippet.sql');
const snippet = readFileSync(snippetPath, 'utf8');
const decl = snippet
.split('\n')
.filter((l) => l.includes('text[] :='))
.join('\n');
const n8 = [
[1, 'Maren Thistlewood'],
[2, 'Finn Marlow'],
[3, 'Asha Kentwell'],
[4, 'Halric Morrow'],
[5, 'Wynn Cartwright'],
[6, 'Orin Aldgate'],
[7, 'Osbert Waynwood'],
[8, 'Liora Selwyn'],
];
const n926 = [
[9, 'npc.brandric_thacker.v1', 'Brandric Thacker'],
[10, 'npc.conrad_pitwright.v1', 'Conrad Pitwright'],
[11, 'npc.nessa_bramble.v1', 'Nessa Bramble'],
[12, 'npc.torin_marshwick.v1', 'Torin Marshwick'],
[13, 'npc.renulf_broadmere.v1', 'Renulf Broadmere'],
[14, 'npc.kael_ironwright.v1', 'Kael Ironwright'],
[15, 'npc.edmund_cinderwell.v1', 'Edmund Cinderwell'],
[16, 'npc.aelith_northgate.v1', 'Aelith Northgate'],
[17, 'npc.dorian_hawke.v1', 'Dorian Hawke'],
[18, 'npc.mariel_starling.v1', 'Mariel Starling'],
[19, 'npc.milo_ropewalk.v1', 'Milo Ropewalk'],
[20, 'npc.lissa_harcourt.v1', 'Lissa Harcourt'],
[21, 'npc.jasper_kindling.v1', 'Jasper Kindling'],
[22, 'npc.kess_wiley.v1', 'Kess Wiley'],
[23, 'npc.aldwin_relicton.v1', 'Aldwin Relicton'],
[24, 'npc.torvik_grimstad.v1', 'Torvik Grimstad'],
[25, 'npc.morna_fenwick.v1', 'Morna Fenwick'],
[26, 'npc.morah_ellis.v1', 'Morah Ellis'],
];
const n27108 = [
[27, 'Sera Whitcomb'],
[28, 'Bram Ashcombe'],
[29, 'Nils Copperton'],
[30, 'Mara Tinwell'],
[31, 'Agnes Stillwater'],
[32, 'Rodrick Cantrell'],
[33, 'Wulfric Strand'],
[34, 'Jada Boltwright'],
[35, 'Alaric Motlow'],
[36, 'Percival Pike'],
[37, 'Eadric Ashenford'],
[38, 'Yoric Scarn'],
[39, 'Rillian Hereward'],
[40, 'Tove Millerson'],
[41, 'Gareth Grantham'],
[42, 'Renulf Sackville'],
[43, 'Bernard Lukin'],
[44, 'Aldwin Grimston'],
[45, 'Edmund Edgerton'],
[46, 'Crispin Aylesford'],
[47, 'Brunhild Flint'],
[48, 'Oren Starward'],
[49, 'Simon Spirewell'],
[50, 'Hugh Comstock'],
[51, 'Yves Portier'],
[52, 'Cedric Brinewell'],
[53, 'Osmund Salter'],
[54, 'Rhys Reedman'],
[55, 'Godfrey Middleton'],
[56, 'Wystan Postlethwaite'],
[57, 'Ivo Ironside'],
[58, 'Roland Rivett'],
[59, 'Lucan Forrest'],
[60, 'Alaric Boghurst'],
[61, 'Norbert Fenwick'],
[62, 'Miles Myreham'],
[63, 'Cuthbert Reed'],
[64, 'Wendel Marsham'],
[65, 'Sigurd Dunstan'],
[66, 'Silas Siltwell'],
[67, 'Peter Sanderson'],
[68, 'Griselda Holt'],
[69, 'Bartholomew Howe'],
[70, 'Baldwin Bonewright'],
[71, 'Cole Aldridge'],
[72, 'Shadrach Morrow'],
[73, 'Rowan Mistwell'],
[74, 'Fergus Fogarty'],
[75, 'Dewi Tarrant'],
[76, 'Vespasian Vale'],
[77, 'Hugo Holloway'],
[78, 'Meredith Stowe'],
[79, 'Roderick Rotherham'],
[80, 'Beatrice Boghurst'],
[81, 'Ashford Hale'],
[82, 'Cyril Cinders'],
[83, 'Emrys Emberly'],
[84, 'Alicia Ashford'],
[85, 'Thorne Hawthorn'],
[86, 'Brian Briarton'],
[87, 'Rowan Rootwell'],
[88, 'Leofric Leaford'],
[89, 'Galfrid Gales'],
[90, 'Wynstan Windham'],
[91, 'Gustav Merseburg'],
[92, 'Blaise Brissot'],
[93, 'Archibald Frostwick'],
[94, 'Rhys Rimer'],
[95, 'Horace Hoarwell'],
[96, 'Isolde Ismay'],
[97, 'Solomon Sunderland'],
[98, 'Clifford Cliffeton'],
[99, 'Craig Cragwell'],
[100, 'Dustin Harwell'],
[101, 'Marshall Fordham'],
[102, 'Rivers Trent'],
[103, 'Bridges Ballard'],
[104, 'Sterling Brook'],
[105, 'Sevrin Veilcourt'],
[106, 'Sterling Starwell'],
[107, 'Neville Nevett'],
[108, 'Vera Veilhart'],
];
function esc(s) {
return s.replace(/'/g, "''");
}
function caseWhen(pairs, col) {
return pairs.map(([id, v]) => ` WHEN ${id} THEN '${esc(v)}'`).join('\n');
}
function caseWhen926() {
const k = n926.map(([id, key]) => ` WHEN ${id} THEN '${esc(key)}'`).join('\n');
const n = n926.map(([id, , name]) => ` WHEN ${id} THEN '${esc(name)}'`).join('\n');
return { k, n };
}
const c926 = caseWhen926();
const sql = `-- Medieval-style personal names for NPCs; generic elders/medics/stalls use per-id keys and pools (frontend/npcGeneratedNames.ts).
DO $$
DECLARE
${decl}
BEGIN
UPDATE public.npcs SET
name_key = 'npc.elder.byid.' || id::text || '.v1',
name = elder_names[1 + ((id * 3) % 20)]
WHERE name_key = 'npc.town_speaker_generic.v1';
UPDATE public.npcs SET
name_key = 'npc.medic.byid.' || id::text || '.v1',
name = medic_names[1 + ((id * 7) % 40)]
WHERE name_key = 'npc.roadside_medic_generic.v1';
UPDATE public.npcs SET
name_key = 'npc.stall.byid.' || id::text || '.v1',
name = stall_names[1 + ((id * 13) % 112)]
WHERE name_key = 'npc.stall_vendor_generic.v1';
END $$;
UPDATE public.npcs SET name = v.n
FROM (VALUES
('npc.capital.merchant_clerk.v1', 'Hugh Pennington'),
('npc.capital.armorer.v1', 'Raoul d''Aubigny'),
('npc.capital.smith.v1', 'Gilles Ferron'),
('npc.capital.jeweler.v1', 'Ysabel Tremaine'),
('npc.capital.bounty_agent_a.v1', 'Roderick Vaughn'),
('npc.capital.bounty_agent_b.v1', 'Matteo Fabbri'),
('npc.capital.elder.v1', 'Anselm Corwyn'),
('npc.capital.healer.v1', 'Clothilde Mercier'),
('npc.capital.second_armorer.v1', 'Bertrand Hale'),
('npc.capital.second_jeweler.v1', 'Eleonore Rivard')
) AS v(k, n)
WHERE public.npcs.name_key = v.k;
UPDATE public.npcs SET name = CASE id
${caseWhen(n8, 'name')}
END
WHERE id BETWEEN 1 AND 8;
UPDATE public.npcs SET
name_key = CASE id
${c926.k}
END,
name = CASE id
${c926.n}
END
WHERE id BETWEEN 9 AND 27;
`;
// Fix: WHERE id BETWEEN 9 AND 26 not 27
const sqlFixed = sql.replace('WHERE id BETWEEN 9 AND 27', 'WHERE id BETWEEN 9 AND 26');
const part27 = `UPDATE public.npcs SET name = CASE id
${caseWhen(n27108, 'name')}
END
WHERE id BETWEEN 27 AND 108;
`;
const out = sqlFixed + '\n' + part27;
writeFileSync(join(root, 'backend/migrations/000034_npc_medieval_names.sql'), out);
console.log('Wrote 000034_npc_medieval_names.sql');
Loading…
Cancel
Save