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;
|
||||
|
||||
// Turn any <table class="datatable"> into a DataTable. Opt out per-table with
|
||||
// data-datatable="false"; pass options as a JSON object in data-datatable-options.
|
||||
const esc = (v) => {
|
||||
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) {
|
||||
root.querySelectorAll('table.datatable').forEach((table) => {
|
||||
if (table.dataset.datatable === 'false' || DataTable.isDataTable(table)) {
|
||||
return;
|
||||
}
|
||||
let options = { pageLength: 25, order: [] };
|
||||
|
||||
let options = { pageLength: 25, order: [], responsive: false };
|
||||
if (table.dataset.datatableOptions) {
|
||||
try {
|
||||
options = { ...options, ...JSON.parse(table.dataset.datatableOptions) };
|
||||
@@ -28,6 +62,20 @@ function initDataTables(root = document) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
twig:
|
||||
file_name_pattern: '*.twig'
|
||||
form_themes: ['bootstrap_5_layout.html.twig']
|
||||
|
||||
when@test:
|
||||
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;
|
||||
|
||||
use App\Entity\Npc;
|
||||
use App\Form\NpcType;
|
||||
use App\Repository\NpcRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
@@ -10,9 +16,71 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class NpcController extends AbstractController
|
||||
{
|
||||
#[Route('/npcs', name: 'app_npcs')]
|
||||
#[Route('/npcs', name: 'app_npcs', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
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 body %}
|
||||
{{ include('_partials/page_placeholder.html.twig', {
|
||||
title: "NPC's",
|
||||
icon: '🧙',
|
||||
blurb: 'Non-player characters will live here — searchable list with faction, location and notes. Data model to be designed.',
|
||||
}) }}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="h3 mb-0">NPC's</h1>
|
||||
</div>
|
||||
|
||||
<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 %}
|
||||
|
||||
Reference in New Issue
Block a user