diff --git a/backend/internal/model/npc_roles.go b/backend/internal/model/npc_roles.go new file mode 100644 index 0000000..29cdf19 --- /dev/null +++ b/backend/internal/model/npc_roles.go @@ -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 +} diff --git a/backend/internal/model/npc_roles_test.go b/backend/internal/model/npc_roles_test.go new file mode 100644 index 0000000..458fab8 --- /dev/null +++ b/backend/internal/model/npc_roles_test.go @@ -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") + } +} diff --git a/backend/migrations/000032_npc_types_quest_split_vendor_roles.sql b/backend/migrations/000032_npc_types_quest_split_vendor_roles.sql new file mode 100644 index 0000000..a6c1c67 --- /dev/null +++ b/backend/migrations/000032_npc_types_quest_split_vendor_roles.sql @@ -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); diff --git a/backend/migrations/000033_capital_hub_and_town_npc_targets.sql b/backend/migrations/000033_capital_hub_and_town_npc_targets.sql new file mode 100644 index 0000000..d0f2e7b --- /dev/null +++ b/backend/migrations/000033_capital_hub_and_town_npc_targets.sql @@ -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); diff --git a/backend/migrations/000034_npc_medieval_names.sql b/backend/migrations/000034_npc_medieval_names.sql new file mode 100644 index 0000000..d25e7df --- /dev/null +++ b/backend/migrations/000034_npc_medieval_names.sql @@ -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; diff --git a/docs/art_reference.png b/docs/art_reference.png new file mode 100644 index 0000000..b169f72 Binary files /dev/null and b/docs/art_reference.png differ diff --git a/docs/sprites_example.png b/docs/sprites_example.png new file mode 100644 index 0000000..a92748c Binary files /dev/null and b/docs/sprites_example.png differ diff --git a/docs/sprites_example_0.png b/docs/sprites_example_0.png new file mode 100644 index 0000000..f1e9b3a Binary files /dev/null and b/docs/sprites_example_0.png differ diff --git a/docs/sprites_instruction.md b/docs/sprites_instruction.md new file mode 100644 index 0000000..45cca1b --- /dev/null +++ b/docs/sprites_instruction.md @@ -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.5–1.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: 6–8 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: 96–128 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: 64–128 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** diff --git a/frontend/src/i18n/npcGeneratedNames.ts b/frontend/src/i18n/npcGeneratedNames.ts new file mode 100644 index 0000000..7ddca94 --- /dev/null +++ b/frontend/src/i18n/npcGeneratedNames.ts @@ -0,0 +1,210 @@ +import type { Locale } from './localeCodes'; + +type Bilingual = { en: string; ru: string }; + +/** Pool for `npc.elder.byid..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..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..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; +} diff --git a/scripts/_npc_name_arrays_snippet.sql b/scripts/_npc_name_arrays_snippet.sql new file mode 100644 index 0000000..0f62186 --- /dev/null +++ b/scripts/_npc_name_arrays_snippet.sql @@ -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[]; diff --git a/scripts/gen-npc-sql-arrays.mjs b/scripts/gen-npc-sql-arrays.mjs new file mode 100644 index 0000000..36afba8 --- /dev/null +++ b/scripts/gen-npc-sql-arrays.mjs @@ -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); diff --git a/scripts/write-npc-migration-034.mjs b/scripts/write-npc-migration-034.mjs new file mode 100644 index 0000000..eb2f01c --- /dev/null +++ b/scripts/write-npc-migration-034.mjs @@ -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');