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:
+51
-3
@@ -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) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
|
}[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,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
@@ -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
|
||||||
|
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">← 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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user