Add NPC database table with server-side DataTable and per-NPC game notes

- Npc entity (village spreadsheet columns) + game_notes text field for
  the DM's live play notes, plus updated_at touched on every change
- Race / VillageStatus / BloodCartInvolvement / MysteryImportance as
  string-backed PHP enums, rendered as dropdowns on the edit form
- /npcs: server-side DataTables (paging, global search and column sort
  all run in the backend via NpcRepository::findForDataTable); name cell
  links to the edit page, game notes shown truncated
- /npcs/{id}: Bootstrap-themed edit form (all fields + a large game
  notes textarea); Symfony forms now use the bootstrap_5 theme
- app.js: generic server-side DataTable wiring driven by data-* on the
  <table>/<th> (data-datatable-src, data-row-url, data-format)
- app:npc:import console command - upserts from data/npcs.csv by
  (family, name), never overwrites game_notes; --purge / --dry-run
- data/npcs.csv: 180 villagers exported from the working spreadsheet

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Frank
2026-09-06 15:04:39 +02:00
co-authored by Claude Sonnet 5
parent cc4fbf425d
commit 1ccab6ed68
15 changed files with 1010 additions and 9 deletions
+51 -3
View File
@@ -13,14 +13,48 @@ import DataTable from 'datatables.net-bs5';
window.bootstrap = bootstrap; window.bootstrap = bootstrap;
// Turn any <table class="datatable"> into a DataTable. Opt out per-table with const esc = (v) => {
// data-datatable="false"; pass options as a JSON object in data-datatable-options. if (v === null || v === undefined) return '';
return String(v).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
};
// Build a DataTables column definition from a <th> in the table head.
// data-data key in the row object (required for server-side tables)
// data-orderable "false" to disable sorting on the column
// data-format "link" | "truncate" | (default: plain escaped text)
// data-row-url (on the <table>) URL template with __ID__ for "link" columns
// NB: "format" not "render" - DataTables itself consumes a data-render attribute.
function columnFromTh(th, rowUrl) {
const key = th.dataset.data;
const col = { data: key || null, orderable: th.dataset.orderable !== 'false' };
if (th.dataset.format === 'link' && rowUrl) {
col.render = (val, type, row) => {
if (type !== 'display') return val ?? '';
return `<a href="${esc(rowUrl.replace('__ID__', row.id))}">${esc(val)}</a>`;
};
} else if (th.dataset.format === 'truncate') {
col.render = (val, type) => {
if (type !== 'display') return val ?? '';
const s = val ? String(val) : '';
const short = s.length > 70 ? s.slice(0, 70) + '…' : s;
return `<span title="${esc(s)}">${esc(short)}</span>`;
};
} else {
col.render = (val, type) => (type === 'display' ? esc(val) : (val ?? ''));
}
return col;
}
function initDataTables(root = document) { function initDataTables(root = document) {
root.querySelectorAll('table.datatable').forEach((table) => { root.querySelectorAll('table.datatable').forEach((table) => {
if (table.dataset.datatable === 'false' || DataTable.isDataTable(table)) { if (table.dataset.datatable === 'false' || DataTable.isDataTable(table)) {
return; return;
} }
let options = { pageLength: 25, order: [] };
let options = { pageLength: 25, order: [], responsive: false };
if (table.dataset.datatableOptions) { if (table.dataset.datatableOptions) {
try { try {
options = { ...options, ...JSON.parse(table.dataset.datatableOptions) }; options = { ...options, ...JSON.parse(table.dataset.datatableOptions) };
@@ -28,6 +62,20 @@ function initDataTables(root = document) {
console.error('Invalid data-datatable-options on', table, e); console.error('Invalid data-datatable-options on', table, e);
} }
} }
// Server-side mode: the backend does the paging, sorting and searching.
if (table.dataset.datatableSrc) {
const ths = [...table.querySelectorAll('thead th')];
options = {
...options,
serverSide: true,
processing: true,
ajax: { url: table.dataset.datatableSrc },
columns: ths.map((th) => columnFromTh(th, table.dataset.rowUrl)),
order: options.order.length ? options.order : [[0, 'asc']],
};
}
new DataTable(table, options); new DataTable(table, options);
}); });
} }
+1
View File
@@ -1,5 +1,6 @@
twig: twig:
file_name_pattern: '*.twig' file_name_pattern: '*.twig'
form_themes: ['bootstrap_5_layout.html.twig']
when@test: when@test:
twig: twig:
+181
View File
@@ -0,0 +1,181 @@
Family,Name,Age,Race,Occupation,Relationships / Notes,Village Status,Blood Cart Involvement,Mystery Importance
Voss,Garrick Voss,61,Human,Council elder,"Husband of Elira, father of Maren and Tobin",Established villager,Council oversight,High
Voss,Elira Voss,58,Human,Housekeeper / herbalist,Wife of Garrick,Established villager,None,Low
Voss,Maren Voss,34,Human,Farmer,Daughter of Garrick and Elira,Established villager,Occasional donor,Low
Voss,Tobin Voss,36,Human,Carpenter,"Son of Garrick and Elira, husband of Lena",Established villager,Occasional donor,Low
Voss,Lena Voss,34,Human,Carpenter,Wife of Tobin,Established villager,Occasional donor,Low
Voss,Mira Voss,7,Human,Child,Daughter of Tobin and Lena,Young villager,None,Low
Voss,Sten Voss,5,Human,Child,Son of Maren; grandson of Garrick and Elira,Young villager,None,Low
Alden,Hugo Alden,52,Human,Farmer,"Husband of Mira, father of Joren, Elsa, Perrin",Established villager,Donor,Low
Alden,Mira Alden,49,Human,Farmer,Wife of Hugo,Established villager,Donor,Low
Alden,Joren Alden,22,Human,Farmer,Son of Hugo and Mira,Established villager,Donor,Low
Alden,Elsa Alden,19,Human,Weaver apprentice,Daughter of Hugo and Mira,Established villager,Donor,Low
Alden,Perrin Alden,15,Human,Farm apprentice,Youngest child of Hugo and Mira,Newly eligible donor,Donor,Low
Green,Matthias Green,57,Human,Farmer,Husband of Sella,Established villager,Donor,Low
Green,Sella Green,55,Human,Farmer,Wife of Matthias,Established villager,Donor,Low
Green,Corin Green,27,Human,Farmer,Son of Matthias and Sella,Established villager,Donor,Low
Green,Nessa Green,24,Human,Miller apprentice,Daughter of Matthias and Sella,Established villager,Donor,Low
Green,Willem Green,17,Human,Farmhand,Son of Matthias and Sella,Established villager,Donor,Low
Fen,Orrin Fen,50,Human,Farmer,Husband of Talia,Established villager,Donor,Low
Fen,Talia Fen,47,Human,Farmer,Wife of Orrin,Established villager,Donor,Low
Fen,Rusk Fen,25,Human,Farmer,Son of Orrin and Talia,Established villager,Donor,Low
Fen,Ilyra Fen,20,Human,Healer apprentice,Daughter of Orrin and Talia,Established villager,Donor,Medium
Fen,Bram Fen,14,Human,Shepherd apprentice,Son of Orrin and Talia,Young villager,None,Low
Barrow,Edric Barrow,54,Human,Livestock keeper,Husband of Marta,Established villager,Donor,Low
Barrow,Marta Barrow,51,Human,Dairy worker,Wife of Edric,Established villager,Donor,Low
Barrow,Finn Barrow,29,Human,Livestock keeper,Son of Edric and Marta,Established villager,Donor,Low
Barrow,Anya Barrow,26,Human,Dairy worker,Daughter-in-law of Edric and Marta,Established villager,Donor,Low
Barrow,Pia Barrow,8,Human,Child,Daughter of Finn and Anya,Young villager,None,Low
Barrow,Cob Barrow,4,Human,Child,"Son of Finn and Anya, younger brother of Pia",Young villager,None,Low
Blackthorn,Ronan Blackthorn,46,Human,Hunter,Husband of Maeve,Established villager,Donor,Low
Blackthorn,Maeve Blackthorn,43,Human,Hunter / trapper,Wife of Ronan,Established villager,Donor,Low
Blackthorn,Kellan Blackthorn,21,Human,Hunter apprentice,Son of Ronan and Maeve,Established villager,Donor,Low
Blackthorn,Suri Blackthorn,18,Human,Trapper apprentice,Daughter of Ronan and Maeve,Established villager,Donor,Medium
Blackthorn,Dain Blackthorn,11,Human,Child,Son of Ronan and Maeve,Young villager,None,Low
Blackthorn,Wynn Blackthorn,7,Human,Child,"Daughter of Ronan and Maeve, younger sister of Dain",Young villager,None,Low
Cooper,Bastian Cooper,59,Human,Cooper / blood-cart driver,"Husband of Nora, long-time cart driver",Established villager,Blood-cart driver,High
Cooper,Nora Cooper,56,Human,Cooper,Wife of Bastian,Established villager,None,Low
Cooper,Evan Cooper,31,Human,Cooper,Son of Bastian and Nora,Established villager,Donor,Low
Cooper,Jessa Cooper,28,Human,Cooper,Daughter-in-law of Bastian and Nora,Established villager,Donor,Low
Cooper,Milo Cooper,6,Human,Child,Son of Evan and Jessa,Young villager,None,Low
Cooper,Tam Cooper,3,Human,Child,"Daughter of Evan and Jessa, younger sister of Milo",Young villager,None,Low
Miller,Halric Miller,63,Human,Miller,Husband of Dora,Established villager,Donor,Low
Miller,Dora Miller,60,Human,Miller's wife,Wife of Halric,Established villager,Donor,Low
Miller,Tess Miller,33,Human,Mill assistant,Daughter of Halric and Dora,Established villager,Donor,Low
Miller,Jon Miller,30,Human,Mill worker,Son of Halric and Dora,Established villager,Donor,Low
Miller,Pavel Miller,16,Human,Mill apprentice,"Apprentice from nearby village, recently completed local ceremony",Adopted villager,Donor,Medium
Miller,Pell Miller,4,Human,Child,Son of Jon; grandson of Halric and Dora,Young villager,None,Low
Ironhand,Durn Ironhand,84,Dwarf,Blacksmith,"Husband of Kara, family head",Established villager,Donor,Low
Ironhand,Kara Ironhand,79,Dwarf,Armorer,Wife of Durn,Established villager,Donor,Low
Ironhand,Borin Ironhand,46,Dwarf,Blacksmith,Son of Durn and Kara,Established villager,Donor,Low
Ironhand,Mira Ironhand,43,Dwarf,Metalworker,Daughter-in-law of Durn and Kara,Established villager,Donor,Low
Ironhand,Tomas Ironhand,17,Dwarf,Blacksmith apprentice,Son of Borin and Mira,Established villager,Donor,Low
Ironhand,Vidra Ironhand,12,Dwarf,Child,"Daughter of Borin and Mira, younger sister of Tomas",Young villager,None,Low
Willow,Rhea Willow,48,Human,Weaver,Wife of Soren,Established villager,Donor,Low
Willow,Soren Willow,50,Human,Dyer,Husband of Rhea,Established villager,Donor,Low
Willow,Mina Willow,23,Human,Weaver,Daughter of Rhea and Soren,Established villager,Donor,Low
Willow,Jace Willow,20,Human,Weaver apprentice,Son of Rhea and Soren,Established villager,Donor,Low
Willow,Elin Willow,15,Human,Spinner apprentice,Daughter of Rhea and Soren,Newly eligible donor,Donor,Low
Oakridge,Harlan Oakridge,55,Human,Woodcutter,Husband of Bess,Established villager,Donor,Low
Oakridge,Bess Oakridge,53,Human,Woodcutter,Wife of Harlan,Established villager,Donor,Low
Oakridge,Niall Oakridge,26,Human,Woodcutter,Son of Harlan and Bess,Established villager,Donor,Low
Oakridge,Rin Oakridge,24,Human,Carpenter apprentice,Daughter of Harlan and Bess,Established villager,Donor,Low
Oakridge,Odo Oakridge,13,Human,Wood gatherer,Son of Harlan and Bess,Young villager,None,Low
Wood,Cedric Wood,49,Human,Carpenter,Husband of Mara,Established villager,Donor,Low
Wood,Mara Wood,46,Human,Carpenter,Wife of Cedric,Established villager,Donor,Low
Wood,Tarin Wood,19,Human,Carpenter apprentice,Son of Cedric and Mara,Established villager,Donor,Low
Wood,Lio Wood,16,Human,Carpenter apprentice,Apprentice from another village,Adopted villager,Donor,Low
Stone,Brannik Stone,58,Dwarf,Mason,Husband of Edda,Established villager,Donor,Low
Stone,Edda Stone,55,Dwarf,Mason,Wife of Brannik,Established villager,Donor,Low
Stone,Korin Stone,29,Dwarf,Mason,Son of Brannik and Edda,Established villager,Donor,Low
Stone,Vara Stone,25,Dwarf,Stoneworker,Daughter-in-law of Brannik and Edda,Established villager,Donor,Low
Stone,Niko Stone,12,Dwarf,Child,Son of Korin and Vara,Young villager,None,Low
Stone,Dorn Stone,8,Dwarf,Child,"Son of Korin and Vara, younger brother of Niko",Young villager,None,Low
Tanner,Gideon Tanner,44,Human,Tanner,Husband of Ruth,Established villager,Donor,Low
Tanner,Ruth Tanner,42,Human,Tanner,Wife of Gideon,Established villager,Donor,Low
Tanner,Cal Tanner,18,Human,Tanner apprentice,Son of Gideon and Ruth,Established villager,Donor,Low
Tanner,Iris Tanner,14,Human,Child,Daughter of Gideon and Ruth,Young villager,None,Low
Tanner,Hob Tanner,8,Human,Child,"Son of Gideon and Ruth, younger brother of Iris",Young villager,None,Low
Cobb,Merrin Cobb,51,Human,Shoemaker,Husband of Agnes,Established villager,Donor,Low
Cobb,Agnes Cobb,48,Human,Shoemaker,Wife of Merrin,Established villager,Donor,Low
Cobb,Lukas Cobb,21,Human,Shoemaker apprentice,Son of Merrin and Agnes,Established villager,Donor,Low
Cobb,Nell Cobb,10,Human,Child,Daughter of Merrin and Agnes,Young villager,None,Low
Brewster,Oswin Brewster,62,Human,Brewer,Husband of Hettie,Established villager,Donor,Low
Brewster,Hettie Brewster,59,Human,Tavern keeper,Wife of Oswin,Established villager,Donor,Low
Brewster,Ralf Brewster,35,Human,Brewer,Son of Oswin and Hettie,Established villager,Donor,Low
Brewster,Mara Brewster,31,Human,Tavern worker,Daughter-in-law of Oswin and Hettie,Established villager,Donor,Low
Brewster,Pip Brewster,7,Human,Child,Child of Ralf and Mara,Young villager,None,Low
Brewster,Nib Brewster,4,Human,Child,"Child of Ralf and Mara, younger sibling of Pip",Young villager,None,Low
Hearth,Jonas Hearth,57,Human,Innkeeper,Husband of Faye,Established villager,Donor,Low
Hearth,Faye Hearth,54,Human,Innkeeper,Wife of Jonas,Established villager,Donor,Low
Hearth,Cora Hearth,25,Human,Inn worker,Daughter of Jonas and Faye,Established villager,Donor,Low
Hearth,Eli Hearth,22,Human,Stable hand,Son of Jonas and Faye,Established villager,Donor,Low
Hearth,May Hearth,16,Human,Inn apprentice,Daughter of Jonas and Faye,Established villager,Donor,Low
Temple,Father Aldus,68,Human,Priest,"Village priest, trusted elder",Established villager,Donor,Medium
Temple,Mara Temple,64,Human,Temple caretaker,Sister of Father Aldus,Established villager,Donor,Medium
Temple,Jonah Temple,29,Human,Acolyte,Son of Mara,Established villager,Donor,Medium
Temple,Lysa Temple,21,Human,Acolyte,Daughter of Mara,Established villager,Donor,Medium
Wooler,Tomas Wooler,53,Human,Shepherd,Husband of Greta,Established villager,Donor,Low
Wooler,Greta Wooler,50,Human,Spinner,Wife of Tomas,Established villager,Donor,Low
Wooler,Merek Wooler,27,Human,Shepherd,Son of Tomas and Greta,Established villager,Donor,Low
Wooler,Sana Wooler,24,Human,Spinner,Daughter of Tomas and Greta,Established villager,Donor,Low
Wooler,Pella Wooler,9,Human,Child,Daughter of Merek,Young villager,None,Low
Wooler,Bede Wooler,6,Human,Child,"Son of Merek, younger brother of Pella",Young villager,None,Low
Bell,Hugh Bell,47,Human,Baker,Husband of Marta,Established villager,Donor,Low
Bell,Marta Bell,44,Human,Baker,Wife of Hugh,Established villager,Donor,Low
Bell,Ned Bell,19,Human,Baker apprentice,Son of Hugh and Marta,Established villager,Donor,Low
Bell,Elsie Bell,13,Human,Child,Daughter of Hugh and Marta,Young villager,None,Low
Clay,Orin Clay,45,Human,Potter,Husband of Dessa,Established villager,Donor,Low
Clay,Dessa Clay,42,Human,Potter,Wife of Orin,Established villager,Donor,Low
Clay,Fenn Clay,17,Human,Potter apprentice,Son of Orin and Dessa,Established villager,Donor,Low
Clay,Til Clay,10,Human,Child,"Daughter of Orin and Dessa, younger sister of Fenn",Young villager,None,Low
Fletcher,Arlen Fletcher,39,Human,Fletcher,Husband of Tessa,Established villager,Donor,Low
Fletcher,Tessa Fletcher,37,Human,Fletcher assistant,Wife of Arlen,Established villager,Donor,Low
Fletcher,Kip Fletcher,15,Human,Fletcher apprentice,Son of Arlen and Tessa,Newly eligible donor,Donor,Low
Fletcher,Doran Fletcher,8,Human,Child,"Son of Arlen and Tessa, younger brother of Kip",Young villager,None,Low
Reed,Jasper Reed,48,Human,Fisher,Husband of Mina,Established villager,Donor,Low
Reed,Mina Reed,45,Human,Fisher,Wife of Jasper,Established villager,Donor,Low
Reed,Tom Reed,22,Human,Fisher,Son of Jasper and Mina,Established villager,Donor,Low
Reed,Nia Reed,18,Human,Fisher apprentice,Daughter of Jasper and Mina,Established villager,Donor,Low
Marr,Gareth Marr,50,Human,Guard,Husband of Elena,Established villager,Donor,Medium
Marr,Elena Marr,47,Human,Guard,Wife of Gareth,Established villager,Donor,Medium
Marr,Rowan Marr,23,Human,Guard,Son of Gareth and Elena,Established villager,Donor,Medium
Marr,Della Marr,20,Human,Guard apprentice,Daughter of Gareth and Elena,Established villager,Donor,Medium
Wheelwright,Roderick Wheelwright,56,Human,Wheelwright,"Widower, family head",Established villager,Donor,Low
Wheelwright,Sanna Wheelwright,53,Human,Wheelwright assistant,Sister of Roderick,Established villager,Donor,Low
Wheelwright,Bert Wheelwright,28,Human,Wheelwright apprentice,Nephew of Roderick,Established villager,Donor,Low
Redbrook,Hester Redbrook,60,Human,Blood collector,Senior blood-collection organizer,Established villager,Blood collector,High
Redbrook,Colm Redbrook,57,Human,Blood collector,Husband of Hester,Established villager,Blood collector,High
Redbrook,Jory Redbrook,32,Human,Blood collector,Son of Hester and Colm,Established villager,Blood collector,High
Redbrook,Mara Redbrook,29,Human,Blood collector,Daughter-in-law of Hester and Colm,Established villager,Blood collector,High
Redbrook,Sable Redbrook,6,Human,Child,Daughter of Jory and Mara; growing up among the blood collectors,Young villager,None,Low
Fairweather,Edwin Fairweather,52,Human,General storekeeper,Husband of Clara,Established villager,Donor,Low
Fairweather,Clara Fairweather,49,Human,Shopkeeper,Wife of Edwin,Established villager,Donor,Low
Fairweather,Nolan Fairweather,23,Human,Shop assistant,Son of Edwin and Clara,Established villager,Donor,Low
Fairweather,Vera Fairweather,18,Human,Shop apprentice,Daughter of Edwin and Clara,Established villager,Donor,Low
Thorn,Bramble Thorn,45,Halfling,Beekeeper,Husband of Poppy,Established villager,Donor,Low
Thorn,Poppy Thorn,42,Halfling,Beekeeper,Wife of Bramble,Established villager,Donor,Low
Thorn,Wren Thorn,19,Halfling,Beekeeper apprentice,Child of Bramble and Poppy,Established villager,Donor,Low
Thorn,Bryony Thorn,8,Halfling,Child,"Daughter of Bramble and Poppy, younger sister of Wren",Young villager,None,Low
Vale,Eamon Vale,41,Human,Farmer / heir,"The inheritance recipient, recently encountered Aldren",Established villager,Donor,Critical
Vale,Maris Vale,39,Human,Housekeeper,"Partner of Eamon, currently caring for household",Established villager,Donor,Medium
Vale,Tilda Vale,17,Human,Apprentice,Daughter of Eamon and Maris,Established villager,Donor,Low
Vale,Rory Vale,12,Human,Child,Son of Eamon and Maris,Young villager,None,Low
Vale,Olda Vale,73,Human,Retired farmer,Eamon's mother,Established villager,Donor,Medium
Vale,Wick Vale,6,Human,Child,Youngest child of Eamon and Maris,Young villager,None,Low
Drift,Celyn Drift,33,Half-Elf,Dyer / apprentice,"Moved from another village, completed local ceremony",Adopted villager,Donor,Medium
Drift,Marlo Drift,35,Human,Cooper apprentice,"Moved from another village, completed local ceremony",Adopted villager,Donor,Low
Drift,Nessa Drift,16,Half-Elf,Apprentice,"Child of Celyn, recently eligible donor",Adopted villager,Donor,Low
Hill,Garran Hill,38,Human,Farmer,Husband of Sera,Established villager,Donor,Low
Hill,Sera Hill,36,Human,Seamstress,Wife of Garran,Established villager,Donor,Low
Hill,Oren Hill,14,Human,Apprentice,Son of Garran and Sera,Young villager,None,Low
Hill,Bryn Hill,6,Human,Child,"Son of Garran and Sera, younger brother of Oren",Young villager,None,Low
Pine,Alda Pine,30,Human,Hunter,Partner of Marek,Established villager,Donor,Low
Pine,Marek Pine,32,Human,Woodworker,Partner of Alda,Established villager,Donor,Low
Pine,Rin Pine,11,Human,Child,Child of Alda and Marek,Young villager,None,Low
Pine,Cael Pine,4,Human,Child,"Child of Alda and Marek, younger sibling of Rin",Young villager,None,Low
Stonebrook,Kellen Stonebrook,28,Human,Mason apprentice,Moved from nearby village,Adopted villager,Donor,Low
Stonebrook,Fara Stonebrook,26,Human,Baker assistant,Partner of Kellen,Adopted villager,Donor,Low
Stonebrook,Miri Stonebrook,5,Human,Child,Child of Kellen and Fara,Young villager,None,Low
Stonebrook,Tovin Stonebrook,2,Human,Child,"Son of Kellen and Fara, younger brother of Miri",Young villager,None,Low
Carter,Alric Carter,45,Human,Farrier / cart repairer,"Lives alone, works closely with Wheelwright family",Established villager,Donor,Low
Carter,Brena Carter,43,Human,Farrier assistant,Sister of Alric,Established villager,Donor,Low
Carter,Milo Carter,18,Human,Farrier apprentice,Nephew of Alric,Established villager,Donor,Low
Orchard,Della Orchard,52,Human,Orchard keeper,"Widow, manages communal fruit trees",Established villager,Donor,Low
Orchard,Seth Orchard,24,Human,Orchard worker,Son of Della,Established villager,Donor,Low
Orchard,Nora Orchard,16,Human,Orchard apprentice,Daughter of Della,Established villager,Donor,Low
Seam,Hilda Seam,59,Human,Seamstress,"Lives near village square, unofficial gossip hub",Established villager,Donor,Medium
Seam,Kara Seam,27,Human,Seamstress apprentice,Daughter of Hilda,Established villager,Donor,Low
Seam,Jory Seam,13,Human,Child,Son of Hilda,Young villager,None,Low
Ash,Corvin Ash,40,Human,Charcoal burner,Husband of Nella,Established villager,Donor,Low
Ash,Nella Ash,38,Human,Charcoal burner,Wife of Corvin,Established villager,Donor,Low
Ash,Perrin Ash,17,Human,Charcoal apprentice,Son of Corvin and Nella,Established villager,Donor,Low
Ash,Lark Ash,9,Human,Child,"Daughter of Corvin and Nella, younger sister of Perrin",Young villager,None,Low
Bellflower,Sella Bellflower,34,Human,Midwife,"Trusted by many families, knows births and deaths",Established villager,Donor,Medium
Bellflower,Aric Bellflower,36,Human,Field worker,Husband of Sella,Established villager,Donor,Low
Bellflower,Mara Bellflower,12,Human,Child,Daughter of Sella and Aric,Young villager,None,Low
Bellflower,Pim Bellflower,5,Human,Child,"Son of Sella and Aric, younger brother of Mara",Young villager,None,Low
Rook,Tarin Rook,48,Human,Messenger / trader,"Travels between villages, knows regional rumors",Established villager,Donor,High
Rook,Elsa Rook,45,Human,Inn assistant,Wife of Tarin,Established villager,Donor,Low
Rook,Jace Rook,19,Human,Messenger apprentice,Son of Tarin and Elsa,Established villager,Donor,Medium
Rook,Mira Rook,9,Human,Child,Daughter of Tarin and Elsa,Young villager,None,Low
1 Family Name Age Race Occupation Relationships / Notes Village Status Blood Cart Involvement Mystery Importance
2 Voss Garrick Voss 61 Human Council elder Husband of Elira, father of Maren and Tobin Established villager Council oversight High
3 Voss Elira Voss 58 Human Housekeeper / herbalist Wife of Garrick Established villager None Low
4 Voss Maren Voss 34 Human Farmer Daughter of Garrick and Elira Established villager Occasional donor Low
5 Voss Tobin Voss 36 Human Carpenter Son of Garrick and Elira, husband of Lena Established villager Occasional donor Low
6 Voss Lena Voss 34 Human Carpenter Wife of Tobin Established villager Occasional donor Low
7 Voss Mira Voss 7 Human Child Daughter of Tobin and Lena Young villager None Low
8 Voss Sten Voss 5 Human Child Son of Maren; grandson of Garrick and Elira Young villager None Low
9 Alden Hugo Alden 52 Human Farmer Husband of Mira, father of Joren, Elsa, Perrin Established villager Donor Low
10 Alden Mira Alden 49 Human Farmer Wife of Hugo Established villager Donor Low
11 Alden Joren Alden 22 Human Farmer Son of Hugo and Mira Established villager Donor Low
12 Alden Elsa Alden 19 Human Weaver apprentice Daughter of Hugo and Mira Established villager Donor Low
13 Alden Perrin Alden 15 Human Farm apprentice Youngest child of Hugo and Mira Newly eligible donor Donor Low
14 Green Matthias Green 57 Human Farmer Husband of Sella Established villager Donor Low
15 Green Sella Green 55 Human Farmer Wife of Matthias Established villager Donor Low
16 Green Corin Green 27 Human Farmer Son of Matthias and Sella Established villager Donor Low
17 Green Nessa Green 24 Human Miller apprentice Daughter of Matthias and Sella Established villager Donor Low
18 Green Willem Green 17 Human Farmhand Son of Matthias and Sella Established villager Donor Low
19 Fen Orrin Fen 50 Human Farmer Husband of Talia Established villager Donor Low
20 Fen Talia Fen 47 Human Farmer Wife of Orrin Established villager Donor Low
21 Fen Rusk Fen 25 Human Farmer Son of Orrin and Talia Established villager Donor Low
22 Fen Ilyra Fen 20 Human Healer apprentice Daughter of Orrin and Talia Established villager Donor Medium
23 Fen Bram Fen 14 Human Shepherd apprentice Son of Orrin and Talia Young villager None Low
24 Barrow Edric Barrow 54 Human Livestock keeper Husband of Marta Established villager Donor Low
25 Barrow Marta Barrow 51 Human Dairy worker Wife of Edric Established villager Donor Low
26 Barrow Finn Barrow 29 Human Livestock keeper Son of Edric and Marta Established villager Donor Low
27 Barrow Anya Barrow 26 Human Dairy worker Daughter-in-law of Edric and Marta Established villager Donor Low
28 Barrow Pia Barrow 8 Human Child Daughter of Finn and Anya Young villager None Low
29 Barrow Cob Barrow 4 Human Child Son of Finn and Anya, younger brother of Pia Young villager None Low
30 Blackthorn Ronan Blackthorn 46 Human Hunter Husband of Maeve Established villager Donor Low
31 Blackthorn Maeve Blackthorn 43 Human Hunter / trapper Wife of Ronan Established villager Donor Low
32 Blackthorn Kellan Blackthorn 21 Human Hunter apprentice Son of Ronan and Maeve Established villager Donor Low
33 Blackthorn Suri Blackthorn 18 Human Trapper apprentice Daughter of Ronan and Maeve Established villager Donor Medium
34 Blackthorn Dain Blackthorn 11 Human Child Son of Ronan and Maeve Young villager None Low
35 Blackthorn Wynn Blackthorn 7 Human Child Daughter of Ronan and Maeve, younger sister of Dain Young villager None Low
36 Cooper Bastian Cooper 59 Human Cooper / blood-cart driver Husband of Nora, long-time cart driver Established villager Blood-cart driver High
37 Cooper Nora Cooper 56 Human Cooper Wife of Bastian Established villager None Low
38 Cooper Evan Cooper 31 Human Cooper Son of Bastian and Nora Established villager Donor Low
39 Cooper Jessa Cooper 28 Human Cooper Daughter-in-law of Bastian and Nora Established villager Donor Low
40 Cooper Milo Cooper 6 Human Child Son of Evan and Jessa Young villager None Low
41 Cooper Tam Cooper 3 Human Child Daughter of Evan and Jessa, younger sister of Milo Young villager None Low
42 Miller Halric Miller 63 Human Miller Husband of Dora Established villager Donor Low
43 Miller Dora Miller 60 Human Miller's wife Wife of Halric Established villager Donor Low
44 Miller Tess Miller 33 Human Mill assistant Daughter of Halric and Dora Established villager Donor Low
45 Miller Jon Miller 30 Human Mill worker Son of Halric and Dora Established villager Donor Low
46 Miller Pavel Miller 16 Human Mill apprentice Apprentice from nearby village, recently completed local ceremony Adopted villager Donor Medium
47 Miller Pell Miller 4 Human Child Son of Jon; grandson of Halric and Dora Young villager None Low
48 Ironhand Durn Ironhand 84 Dwarf Blacksmith Husband of Kara, family head Established villager Donor Low
49 Ironhand Kara Ironhand 79 Dwarf Armorer Wife of Durn Established villager Donor Low
50 Ironhand Borin Ironhand 46 Dwarf Blacksmith Son of Durn and Kara Established villager Donor Low
51 Ironhand Mira Ironhand 43 Dwarf Metalworker Daughter-in-law of Durn and Kara Established villager Donor Low
52 Ironhand Tomas Ironhand 17 Dwarf Blacksmith apprentice Son of Borin and Mira Established villager Donor Low
53 Ironhand Vidra Ironhand 12 Dwarf Child Daughter of Borin and Mira, younger sister of Tomas Young villager None Low
54 Willow Rhea Willow 48 Human Weaver Wife of Soren Established villager Donor Low
55 Willow Soren Willow 50 Human Dyer Husband of Rhea Established villager Donor Low
56 Willow Mina Willow 23 Human Weaver Daughter of Rhea and Soren Established villager Donor Low
57 Willow Jace Willow 20 Human Weaver apprentice Son of Rhea and Soren Established villager Donor Low
58 Willow Elin Willow 15 Human Spinner apprentice Daughter of Rhea and Soren Newly eligible donor Donor Low
59 Oakridge Harlan Oakridge 55 Human Woodcutter Husband of Bess Established villager Donor Low
60 Oakridge Bess Oakridge 53 Human Woodcutter Wife of Harlan Established villager Donor Low
61 Oakridge Niall Oakridge 26 Human Woodcutter Son of Harlan and Bess Established villager Donor Low
62 Oakridge Rin Oakridge 24 Human Carpenter apprentice Daughter of Harlan and Bess Established villager Donor Low
63 Oakridge Odo Oakridge 13 Human Wood gatherer Son of Harlan and Bess Young villager None Low
64 Wood Cedric Wood 49 Human Carpenter Husband of Mara Established villager Donor Low
65 Wood Mara Wood 46 Human Carpenter Wife of Cedric Established villager Donor Low
66 Wood Tarin Wood 19 Human Carpenter apprentice Son of Cedric and Mara Established villager Donor Low
67 Wood Lio Wood 16 Human Carpenter apprentice Apprentice from another village Adopted villager Donor Low
68 Stone Brannik Stone 58 Dwarf Mason Husband of Edda Established villager Donor Low
69 Stone Edda Stone 55 Dwarf Mason Wife of Brannik Established villager Donor Low
70 Stone Korin Stone 29 Dwarf Mason Son of Brannik and Edda Established villager Donor Low
71 Stone Vara Stone 25 Dwarf Stoneworker Daughter-in-law of Brannik and Edda Established villager Donor Low
72 Stone Niko Stone 12 Dwarf Child Son of Korin and Vara Young villager None Low
73 Stone Dorn Stone 8 Dwarf Child Son of Korin and Vara, younger brother of Niko Young villager None Low
74 Tanner Gideon Tanner 44 Human Tanner Husband of Ruth Established villager Donor Low
75 Tanner Ruth Tanner 42 Human Tanner Wife of Gideon Established villager Donor Low
76 Tanner Cal Tanner 18 Human Tanner apprentice Son of Gideon and Ruth Established villager Donor Low
77 Tanner Iris Tanner 14 Human Child Daughter of Gideon and Ruth Young villager None Low
78 Tanner Hob Tanner 8 Human Child Son of Gideon and Ruth, younger brother of Iris Young villager None Low
79 Cobb Merrin Cobb 51 Human Shoemaker Husband of Agnes Established villager Donor Low
80 Cobb Agnes Cobb 48 Human Shoemaker Wife of Merrin Established villager Donor Low
81 Cobb Lukas Cobb 21 Human Shoemaker apprentice Son of Merrin and Agnes Established villager Donor Low
82 Cobb Nell Cobb 10 Human Child Daughter of Merrin and Agnes Young villager None Low
83 Brewster Oswin Brewster 62 Human Brewer Husband of Hettie Established villager Donor Low
84 Brewster Hettie Brewster 59 Human Tavern keeper Wife of Oswin Established villager Donor Low
85 Brewster Ralf Brewster 35 Human Brewer Son of Oswin and Hettie Established villager Donor Low
86 Brewster Mara Brewster 31 Human Tavern worker Daughter-in-law of Oswin and Hettie Established villager Donor Low
87 Brewster Pip Brewster 7 Human Child Child of Ralf and Mara Young villager None Low
88 Brewster Nib Brewster 4 Human Child Child of Ralf and Mara, younger sibling of Pip Young villager None Low
89 Hearth Jonas Hearth 57 Human Innkeeper Husband of Faye Established villager Donor Low
90 Hearth Faye Hearth 54 Human Innkeeper Wife of Jonas Established villager Donor Low
91 Hearth Cora Hearth 25 Human Inn worker Daughter of Jonas and Faye Established villager Donor Low
92 Hearth Eli Hearth 22 Human Stable hand Son of Jonas and Faye Established villager Donor Low
93 Hearth May Hearth 16 Human Inn apprentice Daughter of Jonas and Faye Established villager Donor Low
94 Temple Father Aldus 68 Human Priest Village priest, trusted elder Established villager Donor Medium
95 Temple Mara Temple 64 Human Temple caretaker Sister of Father Aldus Established villager Donor Medium
96 Temple Jonah Temple 29 Human Acolyte Son of Mara Established villager Donor Medium
97 Temple Lysa Temple 21 Human Acolyte Daughter of Mara Established villager Donor Medium
98 Wooler Tomas Wooler 53 Human Shepherd Husband of Greta Established villager Donor Low
99 Wooler Greta Wooler 50 Human Spinner Wife of Tomas Established villager Donor Low
100 Wooler Merek Wooler 27 Human Shepherd Son of Tomas and Greta Established villager Donor Low
101 Wooler Sana Wooler 24 Human Spinner Daughter of Tomas and Greta Established villager Donor Low
102 Wooler Pella Wooler 9 Human Child Daughter of Merek Young villager None Low
103 Wooler Bede Wooler 6 Human Child Son of Merek, younger brother of Pella Young villager None Low
104 Bell Hugh Bell 47 Human Baker Husband of Marta Established villager Donor Low
105 Bell Marta Bell 44 Human Baker Wife of Hugh Established villager Donor Low
106 Bell Ned Bell 19 Human Baker apprentice Son of Hugh and Marta Established villager Donor Low
107 Bell Elsie Bell 13 Human Child Daughter of Hugh and Marta Young villager None Low
108 Clay Orin Clay 45 Human Potter Husband of Dessa Established villager Donor Low
109 Clay Dessa Clay 42 Human Potter Wife of Orin Established villager Donor Low
110 Clay Fenn Clay 17 Human Potter apprentice Son of Orin and Dessa Established villager Donor Low
111 Clay Til Clay 10 Human Child Daughter of Orin and Dessa, younger sister of Fenn Young villager None Low
112 Fletcher Arlen Fletcher 39 Human Fletcher Husband of Tessa Established villager Donor Low
113 Fletcher Tessa Fletcher 37 Human Fletcher assistant Wife of Arlen Established villager Donor Low
114 Fletcher Kip Fletcher 15 Human Fletcher apprentice Son of Arlen and Tessa Newly eligible donor Donor Low
115 Fletcher Doran Fletcher 8 Human Child Son of Arlen and Tessa, younger brother of Kip Young villager None Low
116 Reed Jasper Reed 48 Human Fisher Husband of Mina Established villager Donor Low
117 Reed Mina Reed 45 Human Fisher Wife of Jasper Established villager Donor Low
118 Reed Tom Reed 22 Human Fisher Son of Jasper and Mina Established villager Donor Low
119 Reed Nia Reed 18 Human Fisher apprentice Daughter of Jasper and Mina Established villager Donor Low
120 Marr Gareth Marr 50 Human Guard Husband of Elena Established villager Donor Medium
121 Marr Elena Marr 47 Human Guard Wife of Gareth Established villager Donor Medium
122 Marr Rowan Marr 23 Human Guard Son of Gareth and Elena Established villager Donor Medium
123 Marr Della Marr 20 Human Guard apprentice Daughter of Gareth and Elena Established villager Donor Medium
124 Wheelwright Roderick Wheelwright 56 Human Wheelwright Widower, family head Established villager Donor Low
125 Wheelwright Sanna Wheelwright 53 Human Wheelwright assistant Sister of Roderick Established villager Donor Low
126 Wheelwright Bert Wheelwright 28 Human Wheelwright apprentice Nephew of Roderick Established villager Donor Low
127 Redbrook Hester Redbrook 60 Human Blood collector Senior blood-collection organizer Established villager Blood collector High
128 Redbrook Colm Redbrook 57 Human Blood collector Husband of Hester Established villager Blood collector High
129 Redbrook Jory Redbrook 32 Human Blood collector Son of Hester and Colm Established villager Blood collector High
130 Redbrook Mara Redbrook 29 Human Blood collector Daughter-in-law of Hester and Colm Established villager Blood collector High
131 Redbrook Sable Redbrook 6 Human Child Daughter of Jory and Mara; growing up among the blood collectors Young villager None Low
132 Fairweather Edwin Fairweather 52 Human General storekeeper Husband of Clara Established villager Donor Low
133 Fairweather Clara Fairweather 49 Human Shopkeeper Wife of Edwin Established villager Donor Low
134 Fairweather Nolan Fairweather 23 Human Shop assistant Son of Edwin and Clara Established villager Donor Low
135 Fairweather Vera Fairweather 18 Human Shop apprentice Daughter of Edwin and Clara Established villager Donor Low
136 Thorn Bramble Thorn 45 Halfling Beekeeper Husband of Poppy Established villager Donor Low
137 Thorn Poppy Thorn 42 Halfling Beekeeper Wife of Bramble Established villager Donor Low
138 Thorn Wren Thorn 19 Halfling Beekeeper apprentice Child of Bramble and Poppy Established villager Donor Low
139 Thorn Bryony Thorn 8 Halfling Child Daughter of Bramble and Poppy, younger sister of Wren Young villager None Low
140 Vale Eamon Vale 41 Human Farmer / heir The inheritance recipient, recently encountered Aldren Established villager Donor Critical
141 Vale Maris Vale 39 Human Housekeeper Partner of Eamon, currently caring for household Established villager Donor Medium
142 Vale Tilda Vale 17 Human Apprentice Daughter of Eamon and Maris Established villager Donor Low
143 Vale Rory Vale 12 Human Child Son of Eamon and Maris Young villager None Low
144 Vale Olda Vale 73 Human Retired farmer Eamon's mother Established villager Donor Medium
145 Vale Wick Vale 6 Human Child Youngest child of Eamon and Maris Young villager None Low
146 Drift Celyn Drift 33 Half-Elf Dyer / apprentice Moved from another village, completed local ceremony Adopted villager Donor Medium
147 Drift Marlo Drift 35 Human Cooper apprentice Moved from another village, completed local ceremony Adopted villager Donor Low
148 Drift Nessa Drift 16 Half-Elf Apprentice Child of Celyn, recently eligible donor Adopted villager Donor Low
149 Hill Garran Hill 38 Human Farmer Husband of Sera Established villager Donor Low
150 Hill Sera Hill 36 Human Seamstress Wife of Garran Established villager Donor Low
151 Hill Oren Hill 14 Human Apprentice Son of Garran and Sera Young villager None Low
152 Hill Bryn Hill 6 Human Child Son of Garran and Sera, younger brother of Oren Young villager None Low
153 Pine Alda Pine 30 Human Hunter Partner of Marek Established villager Donor Low
154 Pine Marek Pine 32 Human Woodworker Partner of Alda Established villager Donor Low
155 Pine Rin Pine 11 Human Child Child of Alda and Marek Young villager None Low
156 Pine Cael Pine 4 Human Child Child of Alda and Marek, younger sibling of Rin Young villager None Low
157 Stonebrook Kellen Stonebrook 28 Human Mason apprentice Moved from nearby village Adopted villager Donor Low
158 Stonebrook Fara Stonebrook 26 Human Baker assistant Partner of Kellen Adopted villager Donor Low
159 Stonebrook Miri Stonebrook 5 Human Child Child of Kellen and Fara Young villager None Low
160 Stonebrook Tovin Stonebrook 2 Human Child Son of Kellen and Fara, younger brother of Miri Young villager None Low
161 Carter Alric Carter 45 Human Farrier / cart repairer Lives alone, works closely with Wheelwright family Established villager Donor Low
162 Carter Brena Carter 43 Human Farrier assistant Sister of Alric Established villager Donor Low
163 Carter Milo Carter 18 Human Farrier apprentice Nephew of Alric Established villager Donor Low
164 Orchard Della Orchard 52 Human Orchard keeper Widow, manages communal fruit trees Established villager Donor Low
165 Orchard Seth Orchard 24 Human Orchard worker Son of Della Established villager Donor Low
166 Orchard Nora Orchard 16 Human Orchard apprentice Daughter of Della Established villager Donor Low
167 Seam Hilda Seam 59 Human Seamstress Lives near village square, unofficial gossip hub Established villager Donor Medium
168 Seam Kara Seam 27 Human Seamstress apprentice Daughter of Hilda Established villager Donor Low
169 Seam Jory Seam 13 Human Child Son of Hilda Young villager None Low
170 Ash Corvin Ash 40 Human Charcoal burner Husband of Nella Established villager Donor Low
171 Ash Nella Ash 38 Human Charcoal burner Wife of Corvin Established villager Donor Low
172 Ash Perrin Ash 17 Human Charcoal apprentice Son of Corvin and Nella Established villager Donor Low
173 Ash Lark Ash 9 Human Child Daughter of Corvin and Nella, younger sister of Perrin Young villager None Low
174 Bellflower Sella Bellflower 34 Human Midwife Trusted by many families, knows births and deaths Established villager Donor Medium
175 Bellflower Aric Bellflower 36 Human Field worker Husband of Sella Established villager Donor Low
176 Bellflower Mara Bellflower 12 Human Child Daughter of Sella and Aric Young villager None Low
177 Bellflower Pim Bellflower 5 Human Child Son of Sella and Aric, younger brother of Mara Young villager None Low
178 Rook Tarin Rook 48 Human Messenger / trader Travels between villages, knows regional rumors Established villager Donor High
179 Rook Elsa Rook 45 Human Inn assistant Wife of Tarin Established villager Donor Low
180 Rook Jace Rook 19 Human Messenger apprentice Son of Tarin and Elsa Established villager Donor Medium
181 Rook Mira Rook 9 Human Child Daughter of Tarin and Elsa Young villager None Low
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260906125931 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create npc table (village population + per-NPC game notes)';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE npc (id INT AUTO_INCREMENT NOT NULL, family VARCHAR(120) NOT NULL, name VARCHAR(160) NOT NULL, age SMALLINT DEFAULT NULL, race VARCHAR(255) NOT NULL, occupation VARCHAR(160) DEFAULT NULL, relationships LONGTEXT DEFAULT NULL, village_status VARCHAR(255) NOT NULL, blood_cart_involvement VARCHAR(255) NOT NULL, mystery_importance VARCHAR(255) NOT NULL, game_notes LONGTEXT DEFAULT NULL, updated_at DATETIME DEFAULT NULL, UNIQUE INDEX uniq_npc_family_name (family, name), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE npc');
}
}
+196
View File
@@ -0,0 +1,196 @@
<?php
namespace App\Command;
use App\Entity\Npc;
use App\Enum\BloodCartInvolvement;
use App\Enum\MysteryImportance;
use App\Enum\Race;
use App\Enum\VillageStatus;
use App\Repository\NpcRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
#[AsCommand(
name: 'app:npc:import',
description: 'Import / refresh NPCs from a CSV export of the village spreadsheet.',
)]
class ImportNpcsCommand extends Command
{
/** CSV header => setter-ish key. */
private const COLUMNS = [
'Family', 'Name', 'Age', 'Race', 'Occupation',
'Relationships / Notes', 'Village Status', 'Blood Cart Involvement', 'Mystery Importance',
];
public function __construct(
private readonly EntityManagerInterface $em,
private readonly NpcRepository $npcs,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('file', InputArgument::OPTIONAL, 'CSV path', 'data/npcs.csv')
->addOption('purge', null, InputOption::VALUE_NONE, 'Delete NPCs not present in the file')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report what would change without writing');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
$path = $input->getArgument('file');
if (!is_file($path) && is_file($this->projectDir.'/'.$path)) {
$path = $this->projectDir.'/'.$path;
}
if (!is_readable($path)) {
$io->error(sprintf('File not readable: %s', $path));
return Command::FAILURE;
}
$rows = $this->readCsv($path, $io);
if (null === $rows) {
return Command::FAILURE;
}
// Validate every enum value up front so we fail cleanly, not half-way.
$unknown = [];
foreach ($rows as $line => $r) {
foreach ([
[Race::class, $r['Race']],
[VillageStatus::class, $r['Village Status']],
[BloodCartInvolvement::class, $r['Blood Cart Involvement']],
[MysteryImportance::class, $r['Mystery Importance']],
] as [$enum, $value]) {
if (null === $enum::tryFrom((string) $value)) {
$unknown[] = sprintf('row %d: %s has no case for "%s"', $line + 2, $enum, $value);
}
}
}
if ($unknown) {
$io->error('Unknown enum values - add the case(s) or fix the file:');
$io->listing($unknown);
return Command::FAILURE;
}
$existing = [];
foreach ($this->npcs->findAll() as $npc) {
$existing[$this->key($npc->getFamily(), $npc->getName())] = $npc;
}
$created = $updated = 0;
$seen = [];
foreach ($rows as $r) {
$key = $this->key($r['Family'], $r['Name']);
$seen[$key] = true;
$npc = $existing[$key] ?? null;
if (null === $npc) {
$npc = (new Npc())->setFamily($r['Family'])->setName($r['Name']);
if (!$dryRun) {
$this->em->persist($npc);
}
++$created;
} else {
++$updated;
}
// NOTE: gameNotes is deliberately never written here.
$npc->setAge('' === $r['Age'] ? null : (int) $r['Age'])
->setRace(Race::from($r['Race']))
->setOccupation($r['Occupation'] ?: null)
->setRelationships($r['Relationships / Notes'] ?: null)
->setVillageStatus(VillageStatus::from($r['Village Status']))
->setBloodCartInvolvement(BloodCartInvolvement::from($r['Blood Cart Involvement']))
->setMysteryImportance(MysteryImportance::from($r['Mystery Importance']));
}
$purged = 0;
if ($input->getOption('purge')) {
foreach ($existing as $key => $npc) {
if (!isset($seen[$key])) {
if (!$dryRun) {
$this->em->remove($npc);
}
++$purged;
}
}
}
if (!$dryRun) {
$this->em->flush();
}
$io->success(sprintf(
'%s: %d created, %d updated%s (%d rows in file).',
$dryRun ? 'Dry run' : 'Imported',
$created,
$updated,
$input->getOption('purge') ? ", {$purged} purged" : '',
\count($rows),
));
return Command::SUCCESS;
}
/**
* @return list<array<string, string>>|null
*/
private function readCsv(string $path, SymfonyStyle $io): ?array
{
$fh = fopen($path, 'r');
$header = fgetcsv($fh, escape: '');
if (false === $header) {
$io->error('Empty file.');
fclose($fh);
return null;
}
$header = array_map('trim', $header);
$missing = array_diff(self::COLUMNS, $header);
if ($missing) {
$io->error('Missing columns: '.implode(', ', $missing));
fclose($fh);
return null;
}
$rows = [];
while (false !== ($line = fgetcsv($fh, escape: ''))) {
if (1 === \count($line) && (null === $line[0] || '' === trim((string) $line[0]))) {
continue; // blank line
}
$assoc = [];
foreach ($header as $i => $name) {
$assoc[$name] = trim((string) ($line[$i] ?? ''));
}
if ('' === $assoc['Family'] && '' === $assoc['Name']) {
continue;
}
$rows[] = $assoc;
}
fclose($fh);
return $rows;
}
private function key(string $family, string $name): string
{
return mb_strtolower(trim($family).'|'.trim($name));
}
}
+69 -1
View File
@@ -2,7 +2,13 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\Npc;
use App\Form\NpcType;
use App\Repository\NpcRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -10,9 +16,71 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('IS_AUTHENTICATED_FULLY')] #[IsGranted('IS_AUTHENTICATED_FULLY')]
class NpcController extends AbstractController class NpcController extends AbstractController
{ {
#[Route('/npcs', name: 'app_npcs')] #[Route('/npcs', name: 'app_npcs', methods: ['GET'])]
public function index(): Response public function index(): Response
{ {
return $this->render('npc/index.html.twig'); return $this->render('npc/index.html.twig');
} }
/**
* Server-side processing endpoint for the DataTable on /npcs.
* https://datatables.net/manual/server-side
*/
#[Route('/npcs/data', name: 'app_npcs_data', methods: ['GET'])]
public function data(Request $request, NpcRepository $repo): JsonResponse
{
$q = $request->query;
$draw = (int) $q->get('draw', 1);
$start = (int) $q->get('start', 0);
$length = (int) $q->get('length', 25);
$search = (string) ($q->all('search')['value'] ?? '');
$columns = $q->all('columns');
$order = $q->all('order')[0] ?? ['column' => 0, 'dir' => 'asc'];
$sortKey = $columns[(int) ($order['column'] ?? 0)]['data'] ?? 'family';
$sortDir = (string) ($order['dir'] ?? 'asc');
$result = $repo->findForDataTable($search, $sortKey, $sortDir, $start, $length);
$data = array_map(static fn (Npc $n) => [
'id' => $n->getId(),
'family' => $n->getFamily(),
'name' => $n->getName(),
'age' => $n->getAge(),
'race' => $n->getRace()->value,
'occupation' => $n->getOccupation(),
'relationships' => $n->getRelationships(),
'villageStatus' => $n->getVillageStatus()->value,
'bloodCartInvolvement' => $n->getBloodCartInvolvement()->value,
'mysteryImportance' => $n->getMysteryImportance()->value,
'gameNotes' => $n->getGameNotes(),
'updatedAt' => $n->getUpdatedAt()?->format('Y-m-d H:i'),
], $result['data']);
return $this->json([
'draw' => $draw,
'recordsTotal' => $result['recordsTotal'],
'recordsFiltered' => $result['recordsFiltered'],
'data' => $data,
]);
}
#[Route('/npcs/{id}', name: 'app_npc_edit', requirements: ['id' => '\d+'], methods: ['GET', 'POST'])]
public function edit(Request $request, Npc $npc, EntityManagerInterface $em): Response
{
$form = $this->createForm(NpcType::class, $npc);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->flush();
$this->addFlash('success', sprintf('Saved %s.', $npc->getName()));
return $this->redirectToRoute('app_npc_edit', ['id' => $npc->getId()]);
}
return $this->render('npc/edit.html.twig', [
'npc' => $npc,
'form' => $form,
]);
}
} }
+198
View File
@@ -0,0 +1,198 @@
<?php
namespace App\Entity;
use App\Enum\BloodCartInvolvement;
use App\Enum\MysteryImportance;
use App\Enum\Race;
use App\Enum\VillageStatus;
use App\Repository\NpcRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: NpcRepository::class)]
#[ORM\UniqueConstraint(name: 'uniq_npc_family_name', columns: ['family', 'name'])]
#[ORM\HasLifecycleCallbacks]
class Npc
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 120)]
#[Assert\NotBlank]
private string $family = '';
#[ORM\Column(length: 160)]
#[Assert\NotBlank]
private string $name = '';
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
#[Assert\PositiveOrZero]
private ?int $age = null;
#[ORM\Column(enumType: Race::class)]
private Race $race = Race::Human;
#[ORM\Column(length: 160, nullable: true)]
private ?string $occupation = null;
/** Free text - the spreadsheet's "Relationships / Notes" column. */
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $relationships = null;
#[ORM\Column(enumType: VillageStatus::class)]
private VillageStatus $villageStatus = VillageStatus::Established;
#[ORM\Column(enumType: BloodCartInvolvement::class)]
private BloodCartInvolvement $bloodCartInvolvement = BloodCartInvolvement::None;
#[ORM\Column(enumType: MysteryImportance::class)]
private MysteryImportance $mysteryImportance = MysteryImportance::Low;
/** DM's live notes, written during play. Never touched by the importer. */
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $gameNotes = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
#[ORM\PrePersist]
#[ORM\PreUpdate]
public function touch(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getFamily(): string
{
return $this->family;
}
public function setFamily(string $family): static
{
$this->family = $family;
return $this;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getAge(): ?int
{
return $this->age;
}
public function setAge(?int $age): static
{
$this->age = $age;
return $this;
}
public function getRace(): Race
{
return $this->race;
}
public function setRace(Race $race): static
{
$this->race = $race;
return $this;
}
public function getOccupation(): ?string
{
return $this->occupation;
}
public function setOccupation(?string $occupation): static
{
$this->occupation = $occupation;
return $this;
}
public function getRelationships(): ?string
{
return $this->relationships;
}
public function setRelationships(?string $relationships): static
{
$this->relationships = $relationships;
return $this;
}
public function getVillageStatus(): VillageStatus
{
return $this->villageStatus;
}
public function setVillageStatus(VillageStatus $villageStatus): static
{
$this->villageStatus = $villageStatus;
return $this;
}
public function getBloodCartInvolvement(): BloodCartInvolvement
{
return $this->bloodCartInvolvement;
}
public function setBloodCartInvolvement(BloodCartInvolvement $bloodCartInvolvement): static
{
$this->bloodCartInvolvement = $bloodCartInvolvement;
return $this;
}
public function getMysteryImportance(): MysteryImportance
{
return $this->mysteryImportance;
}
public function setMysteryImportance(MysteryImportance $mysteryImportance): static
{
$this->mysteryImportance = $mysteryImportance;
return $this;
}
public function getGameNotes(): ?string
{
return $this->gameNotes;
}
public function setGameNotes(?string $gameNotes): static
{
$this->gameNotes = $gameNotes;
return $this;
}
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Enum;
enum BloodCartInvolvement: string
{
case None = 'None';
case Donor = 'Donor';
case OccasionalDonor = 'Occasional donor';
case BloodCollector = 'Blood collector';
case BloodCartDriver = 'Blood-cart driver';
case CouncilOversight = 'Council oversight';
public function label(): string
{
return $this->value;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Enum;
enum MysteryImportance: string
{
case Low = 'Low';
case Medium = 'Medium';
case High = 'High';
case Critical = 'Critical';
public function label(): string
{
return $this->value;
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Enum;
enum Race: string
{
case Human = 'Human';
case Dwarf = 'Dwarf';
case Elf = 'Elf';
case HalfElf = 'Half-Elf';
case Halfling = 'Halfling';
case Gnome = 'Gnome';
case HalfOrc = 'Half-Orc';
case Tiefling = 'Tiefling';
case Dragonborn = 'Dragonborn';
case Other = 'Other';
public function label(): string
{
return $this->value;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Enum;
enum VillageStatus: string
{
case Established = 'Established villager';
case Young = 'Young villager';
case Adopted = 'Adopted villager';
case NewlyEligibleDonor = 'Newly eligible donor';
public function label(): string
{
return $this->value;
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Form;
use App\Entity\Npc;
use App\Enum\BloodCartInvolvement;
use App\Enum\MysteryImportance;
use App\Enum\Race;
use App\Enum\VillageStatus;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class NpcType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$enum = static fn (string $class) => [
'class' => $class,
'choice_label' => static fn ($case) => $case->value,
];
$builder
->add('family', TextType::class)
->add('name', TextType::class)
->add('age', IntegerType::class, ['required' => false])
->add('race', EnumType::class, $enum(Race::class))
->add('occupation', TextType::class, ['required' => false])
->add('relationships', TextareaType::class, [
'required' => false,
'label' => 'Relationships / Notes',
'attr' => ['rows' => 3],
])
->add('villageStatus', EnumType::class, $enum(VillageStatus::class))
->add('bloodCartInvolvement', EnumType::class, $enum(BloodCartInvolvement::class))
->add('mysteryImportance', EnumType::class, $enum(MysteryImportance::class))
->add('gameNotes', TextareaType::class, [
'required' => false,
'label' => 'Game notes',
'attr' => ['rows' => 10, 'placeholder' => 'What happened with this NPC during play…'],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => Npc::class]);
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace App\Repository;
use App\Entity\Npc;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Npc>
*/
class NpcRepository extends ServiceEntityRepository
{
/** Fields the DataTable may sort on (column key => DQL field). */
public const SORTABLE = [
'family' => 'n.family',
'name' => 'n.name',
'age' => 'n.age',
'race' => 'n.race',
'occupation' => 'n.occupation',
'villageStatus' => 'n.villageStatus',
'bloodCartInvolvement' => 'n.bloodCartInvolvement',
'mysteryImportance' => 'n.mysteryImportance',
'updatedAt' => 'n.updatedAt',
];
/** Fields the global search box matches against (LIKE, OR-ed). */
private const SEARCHABLE = [
'n.family', 'n.name', 'n.occupation', 'n.relationships', 'n.gameNotes',
'n.race', 'n.villageStatus', 'n.bloodCartInvolvement', 'n.mysteryImportance',
];
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Npc::class);
}
/**
* Server-side data for DataTables.
*
* @return array{data: list<Npc>, recordsTotal: int, recordsFiltered: int}
*/
public function findForDataTable(
?string $search,
string $sortKey,
string $sortDir,
int $start,
int $length,
): array {
$sortField = self::SORTABLE[$sortKey] ?? 'n.family';
$sortDir = strtolower($sortDir) === 'desc' ? 'DESC' : 'ASC';
$length = $length < 0 ? 200 : min($length, 200);
$qb = $this->createQueryBuilder('n');
if (null !== $search && '' !== trim($search)) {
$ors = [];
foreach (self::SEARCHABLE as $i => $field) {
$ors[] = $field.' LIKE :s'.$i;
$qb->setParameter('s'.$i, '%'.trim($search).'%');
}
$qb->andWhere('('.implode(' OR ', $ors).')');
}
$filtered = (int) (clone $qb)->select('COUNT(n.id)')->getQuery()->getSingleScalarResult();
$rows = $qb
->orderBy($sortField, $sortDir)
->addOrderBy('n.name', 'ASC')
->setFirstResult(max(0, $start))
->setMaxResults($length)
->getQuery()
->getResult();
return [
'data' => $rows,
'recordsTotal' => $this->count([]),
'recordsFiltered' => $filtered,
];
}
}
+51
View File
@@ -0,0 +1,51 @@
{% extends 'base.html.twig' %}
{% block title %}{{ npc.name }} · NPC's · dmtools{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<a href="{{ path('app_npcs') }}" class="text-decoration-none small">&larr; All NPC's</a>
<h1 class="h3 mb-0">{{ npc.name }}</h1>
</div>
{% if npc.updatedAt %}
<span class="text-body-secondary small">Updated {{ npc.updatedAt|date('Y-m-d H:i') }}</span>
{% endif %}
</div>
{{ form_start(form) }}
<div class="row g-4">
<div class="col-lg-5">
<div class="card shadow-sm">
<div class="card-header">Details</div>
<div class="card-body">
<div class="row">
<div class="col-8">{{ form_row(form.name) }}</div>
<div class="col-4">{{ form_row(form.age) }}</div>
</div>
{{ form_row(form.family) }}
{{ form_row(form.race) }}
{{ form_row(form.occupation) }}
{{ form_row(form.villageStatus) }}
{{ form_row(form.bloodCartInvolvement) }}
{{ form_row(form.mysteryImportance) }}
{{ form_row(form.relationships) }}
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-header">Game notes</div>
<div class="card-body">
{{ form_row(form.gameNotes, {label: false}) }}
</div>
</div>
</div>
</div>
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary">Save</button>
<a href="{{ path('app_npcs') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
{{ form_end(form) }}
{% endblock %}
+28 -5
View File
@@ -3,9 +3,32 @@
{% block title %}NPC's · dmtools{% endblock %} {% block title %}NPC's · dmtools{% endblock %}
{% block body %} {% block body %}
{{ include('_partials/page_placeholder.html.twig', { <div class="d-flex justify-content-between align-items-center mb-3">
title: "NPC's", <h1 class="h3 mb-0">NPC's</h1>
icon: '🧙', </div>
blurb: 'Non-player characters will live here — searchable list with faction, location and notes. Data model to be designed.',
}) }} <div class="card shadow-sm">
<div class="card-body">
<table class="table table-striped table-hover align-middle datatable w-100"
data-datatable-src="{{ path('app_npcs_data') }}"
data-row-url="{{ path('app_npcs') }}/__ID__"
data-datatable-options='{"order": [[0, "asc"]]}'>
<thead>
<tr>
<th data-data="name" data-format="link">Name</th>
<th data-data="family">Family</th>
<th data-data="age">Age</th>
<th data-data="race">Race</th>
<th data-data="occupation">Occupation</th>
<th data-data="villageStatus">Village status</th>
<th data-data="bloodCartInvolvement">Blood cart</th>
<th data-data="mysteryImportance">Mystery</th>
<th data-data="gameNotes" data-format="truncate" data-orderable="false">Game notes</th>
<th data-data="updatedAt">Updated</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
{% endblock %} {% endblock %}