Skip to content

Commit 702702b

Browse files
committed
feat: standard system hexes; new name; updated user flows
1 parent ac51cfa commit 702702b

30 files changed

Lines changed: 1343 additions & 190 deletions

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22

33
![Hex v2](./public/hex-v2.png)
44

5-
Hex is an application for managing
5+
Project Hex is an application for managing
66
[Hex Flower Engines](https://goblinshenchman.wordpress.com/2018/10/25/2d6-hex-power-flower/)
77
for any tabletop game in the browser.
88

9-
**Disclaimer**: The [Hex Flower Engine](https://goblinshenchman.wordpress.com/hex-power-flower/) implementation that Hex uses was orignally described by [Goblin's Henchman](https://goblinshenchman.wordpress.com). Dead Villager Dead Adventurer Games and the Hex project take no credit for the Hex Flower Engine system and are not affiliated with Goblin's Henchman, but we are grateful for the work they put into designing the system and sharing it with the world. Consider supporting Goblin's Henchman's work by purchasing their products on DriveThruRPG: [https://www.drivethrurpg.com/en/publisher/9524/Goblin039s-Henchmanaffiliate_id%3D774882](https://www.drivethrurpg.com/en/publisher/9524/Goblin039s-Henchmanaffiliate_id%3D774882).
9+
**Disclaimer**: The [Hex Flower Engine](https://goblinshenchman.wordpress.com/hex-power-flower/) implementation that Hex uses was orignally described by [Goblin's Henchman](https://goblinshenchman.wordpress.com). Dead Villager Dead Adventurer Games and Project Hex take no credit for the Hex Flower Engine system and are not affiliated with Goblin's Henchman, but we are grateful for the work they put into designing the system and sharing it with the world. Consider supporting Goblin's Henchman's work by purchasing their products on DriveThruRPG: [https://www.drivethrurpg.com/en/publisher/9524/Goblin039s-Henchmanaffiliate_id%3D774882](https://www.drivethrurpg.com/en/publisher/9524/Goblin039s-Henchmanaffiliate_id%3D774882).
1010

1111
## Create and Share Hex Flower Engines
1212

13-
In the Hex application, you can create your own Hex Flower Engines, share them with your players, and even publish them to the Garden, where other users can find them and use them in their own games.
13+
In the Project Hex application, you can create your own Hex Flower Engines, share them with your players, and even publish them to the Garden, where other users can find them and use them in their own games.
1414

1515
The Hex Editor allows you to choose a label, icon, color, and description for each Hex cell, and also customize the Hex Flower Engine movement rules for each roll outcome for that cell.
1616

@@ -24,11 +24,12 @@ The Hex Editor allows you to choose a label, icon, color, and description for ea
2424

2525
The legacy version of this application is still hosted on GitHub Pages, but `v2` is a much better application for your needs and provides an interface for you to configure your own Hex Flower Engines.
2626

27-
It currently supports two engines:
27+
It currently supports ~~two~~ three engines:
2828

2929
- `Standard Hex Flower Engine`: basic hex flower engine with standard movement rules
3030
as described in the
3131
[article linked above](https://goblinshenchman.wordpress.com/2018/10/25/2d6-hex-power-flower/)
32+
- `Inverse Hex Flower Engine`: standard hex flower engine with inverted movement rules
3233
- `SKT Weather Engine`: modified hex flower engine built for a specific weather
3334
generator for a D&D campaign; rules are described
3435
[here](https://github.com/chrisman/skookums-and-dragons/wiki/House-rules#weather)
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Admin API: Emergency cascade delete of engine and all forks
2+
// DELETE /api/admin/cascade-delete
3+
4+
import type { Env } from '../../types';
5+
import { getAuthUser, json, errorResponse } from '../../utils';
6+
7+
interface CascadeDeleteRequest {
8+
engineId: string;
9+
confirmation: string; // Engine name for confirmation
10+
}
11+
12+
export const onRequestDelete: PagesFunction<Env> = async (context) => {
13+
const { request, env } = context;
14+
15+
// Check authentication
16+
const session = await getAuthUser(request, env);
17+
if (!session) {
18+
return errorResponse('Unauthorized', 401);
19+
}
20+
21+
// Check admin status
22+
const user = await env.DB.prepare(`
23+
SELECT is_admin FROM profiles WHERE id = ?
24+
`).bind(session.userId).first();
25+
26+
if (!user?.is_admin) {
27+
return errorResponse('Admin access required', 403);
28+
}
29+
30+
try {
31+
const body = await request.json() as CascadeDeleteRequest;
32+
33+
if (!body.engineId || !body.confirmation) {
34+
return errorResponse('Engine ID and confirmation are required', 400);
35+
}
36+
37+
// Get the engine and verify confirmation
38+
const engine = await env.DB.prepare(`
39+
SELECT id, definition FROM engines WHERE id = ?
40+
`).bind(body.engineId).first();
41+
42+
if (!engine) {
43+
return errorResponse('Engine not found', 404);
44+
}
45+
46+
const definition = JSON.parse(engine.definition as string);
47+
const engineName = definition.name;
48+
49+
// Verify confirmation matches engine name
50+
if (body.confirmation !== engineName) {
51+
return errorResponse('Confirmation does not match engine name', 400);
52+
}
53+
54+
// Get all forks of this engine
55+
const forks = await env.DB.prepare(`
56+
SELECT id FROM engines WHERE forked_from = ?
57+
`).bind(body.engineId).all();
58+
59+
const forkIds = forks.results.map(f => f.id as string);
60+
61+
// Delete all forks first (CASCADE will handle related tables)
62+
if (forkIds.length > 0) {
63+
const placeholders = forkIds.map(() => '?').join(',');
64+
await env.DB.prepare(`
65+
DELETE FROM engines WHERE id IN (${placeholders})
66+
`).bind(...forkIds).run();
67+
}
68+
69+
// Delete the original engine (CASCADE will handle related tables)
70+
await env.DB.prepare(`
71+
DELETE FROM engines WHERE id = ?
72+
`).bind(body.engineId).run();
73+
74+
const deletedCount = forkIds.length + 1;
75+
76+
console.log(`[ADMIN] Cascade deleted engine ${engineName} (${body.engineId}) and ${forkIds.length} forks by admin ${session.userId}`);
77+
78+
return json({
79+
success: true,
80+
deletedCount,
81+
engineId: body.engineId,
82+
engineName,
83+
forksDeleted: forkIds.length,
84+
});
85+
} catch (error) {
86+
console.error('Cascade delete error:', error);
87+
return errorResponse('Failed to cascade delete engine', 500);
88+
}
89+
};

functions/api/admin/unpublish.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Admin API: Unpublish an engine
2+
// POST /api/admin/unpublish
3+
4+
import type { Env } from '../../types';
5+
import { getAuthUser, json, errorResponse } from '../../utils';
6+
7+
interface UnpublishRequest {
8+
engineId: string;
9+
}
10+
11+
export const onRequestPost: PagesFunction<Env> = async (context) => {
12+
const { request, env } = context;
13+
14+
// Check authentication
15+
const session = await getAuthUser(request, env);
16+
if (!session) {
17+
return errorResponse('Unauthorized', 401);
18+
}
19+
20+
// Check admin status
21+
const user = await env.DB.prepare(`
22+
SELECT is_admin FROM profiles WHERE id = ?
23+
`).bind(session.userId).first();
24+
25+
if (!user?.is_admin) {
26+
return errorResponse('Admin access required', 403);
27+
}
28+
29+
try {
30+
const body = await request.json() as UnpublishRequest;
31+
32+
if (!body.engineId) {
33+
return errorResponse('Engine ID is required', 400);
34+
}
35+
36+
// Check if engine exists and is public
37+
const engine = await env.DB.prepare(`
38+
SELECT id, visibility, definition FROM engines WHERE id = ?
39+
`).bind(body.engineId).first();
40+
41+
if (!engine) {
42+
return errorResponse('Engine not found', 404);
43+
}
44+
45+
if (engine.visibility !== 'public') {
46+
return errorResponse('Engine is not published', 400);
47+
}
48+
49+
const now = new Date().toISOString();
50+
51+
// Unpublish the engine (set to private and record timestamp)
52+
await env.DB.prepare(`
53+
UPDATE engines
54+
SET visibility = 'private',
55+
unpublished_at = ?
56+
WHERE id = ?
57+
`).bind(now, body.engineId).run();
58+
59+
// Return updated engine
60+
return json({
61+
id: engine.id,
62+
visibility: 'private',
63+
unpublishedAt: now,
64+
definition: JSON.parse(engine.definition as string),
65+
});
66+
} catch (error) {
67+
console.error('Unpublish error:', error);
68+
return errorResponse('Failed to unpublish engine', 500);
69+
}
70+
};

functions/api/auth/me.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,25 @@ export const onRequestGet: PagesFunction<Env> = async (context) => {
1414

1515
// Get user profile
1616
const user = await env.DB.prepare(`
17-
SELECT id, email, display_name, avatar_url, is_admin, default_engine_id, created_at, updated_at, accept_terms, hex_newsletter_opt_in, dvda_newsletter_opt_in
17+
SELECT id, email, display_name, avatar_url, is_admin, default_engine_id, default_editor_engine_id, created_at, updated_at, accept_terms, hex_newsletter_opt_in, dvda_newsletter_opt_in
1818
FROM profiles
1919
WHERE id = ?
2020
`).bind(session.userId).first();
21-
21+
2222
if (!user) {
2323
return errorResponse('User not found', 404);
2424
}
2525

2626
console.log("Fetched user profile:", user);
27-
27+
2828
return json({
2929
id: user.id,
3030
email: user.email,
3131
displayName: user.display_name,
3232
avatarUrl: user.avatar_url,
3333
isAdmin: user.is_admin === 1,
3434
defaultEngineId: user.default_engine_id,
35+
defaultEditorEngineId: user.default_editor_engine_id,
3536
createdAt: user.created_at,
3637
updatedAt: user.updated_at,
3738
acceptTerms: user.accept_terms,

functions/api/auth/profile.ts

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,18 @@ export const onRequestPatch: PagesFunction<Env> = async (context) => {
3030
return errorResponse('Unauthorized', 401);
3131
}
3232

33-
let body: { displayName?: string; avatarIcon?: string | null; defaultEngineId?: string | null; acceptTerms?: boolean; hexNewsletterOptIn?: boolean; dvdaNewsletterOptIn?: boolean };
33+
let body: { displayName?: string; avatarIcon?: string | null; defaultEngineId?: string | null; defaultEditorEngineId?: string | null; acceptTerms?: boolean; hexNewsletterOptIn?: boolean; dvdaNewsletterOptIn?: boolean };
3434

3535
try {
3636
body = await request.json();
3737
} catch {
3838
return errorResponse('Invalid JSON body', 400);
3939
}
40-
41-
const { displayName, avatarIcon, defaultEngineId, acceptTerms, hexNewsletterOptIn, dvdaNewsletterOptIn } = body;
40+
41+
const { displayName, avatarIcon, defaultEngineId, defaultEditorEngineId, acceptTerms, hexNewsletterOptIn, dvdaNewsletterOptIn } = body;
4242

4343
const existingUser = await env.DB.prepare(`
44-
SELECT id, email, display_name, avatar_url, is_admin, default_engine_id, accept_terms, hex_newsletter_opt_in, dvda_newsletter_opt_in
44+
SELECT id, email, display_name, avatar_url, is_admin, default_engine_id, default_editor_engine_id, accept_terms, hex_newsletter_opt_in, dvda_newsletter_opt_in
4545
FROM profiles
4646
WHERE id = ?
4747
`).bind(session.userId).first();
@@ -78,13 +78,25 @@ export const onRequestPatch: PagesFunction<Env> = async (context) => {
7878
if (defaultEngineId !== undefined && defaultEngineId !== null) {
7979
// Check that the engine exists and belongs to the user
8080
const engine = await env.DB.prepare(`
81-
SELECT id FROM engines WHERE id = ? AND owner_id = ?
81+
SELECT id FROM engines WHERE id = ? AND (owner_id = ? OR is_system_default = 1)
8282
`).bind(defaultEngineId, session.userId).first();
83-
83+
8484
if (!engine) {
8585
return errorResponse('Engine not found or not owned by user', 400);
8686
}
8787
}
88+
89+
// Validate default editor engine if provided
90+
if (defaultEditorEngineId !== undefined && defaultEditorEngineId !== null) {
91+
// Check that the engine exists and belongs to the user
92+
const engine = await env.DB.prepare(`
93+
SELECT id FROM engines WHERE id = ? AND (owner_id = ? OR is_system_default = 1)
94+
`).bind(defaultEditorEngineId, session.userId).first();
95+
96+
if (!engine) {
97+
return errorResponse('Editor engine not found or not owned by user', 400);
98+
}
99+
}
88100

89101
// Build update query
90102
const updates: string[] = [];
@@ -104,7 +116,12 @@ export const onRequestPatch: PagesFunction<Env> = async (context) => {
104116
updates.push('default_engine_id = ?');
105117
values.push(defaultEngineId);
106118
}
107-
119+
120+
if (defaultEditorEngineId !== undefined) {
121+
updates.push('default_editor_engine_id = ?');
122+
values.push(defaultEditorEngineId);
123+
}
124+
108125
if (acceptTerms !== undefined) {
109126
updates.push('accept_terms = ?');
110127
values.push(acceptTerms ? '1' : '0');
@@ -135,7 +152,7 @@ export const onRequestPatch: PagesFunction<Env> = async (context) => {
135152

136153
// Return updated user
137154
const user = await env.DB.prepare(`
138-
SELECT id, email, display_name, avatar_url, is_admin, default_engine_id, accept_terms, hex_newsletter_opt_in, dvda_newsletter_opt_in
155+
SELECT id, email, display_name, avatar_url, is_admin, default_engine_id, default_editor_engine_id, accept_terms, hex_newsletter_opt_in, dvda_newsletter_opt_in
139156
FROM profiles
140157
WHERE id = ?
141158
`).bind(session.userId).first();
@@ -144,25 +161,23 @@ export const onRequestPatch: PagesFunction<Env> = async (context) => {
144161
return errorResponse('User not found', 404);
145162
}
146163

147-
if (!!existingUser.hex_newsletter_opt_in !== !!hexNewsletterOptIn) {
148-
try {
149-
await subscribeToNewsletter(user?.email || "", env.HEX_NEWSLETTER_ID || "", hexNewsletterOptIn ? 1 : 0, env);
150-
} catch (error) {
151-
console.error("Failed to update Hex newsletter subscription:", error);
164+
if (typeof user.email === "string" && user.email) {
165+
if (!!existingUser.hex_newsletter_opt_in !== !!hexNewsletterOptIn) {
166+
try {
167+
await subscribeToNewsletter(user.email, env.HEX_NEWSLETTER_ID!, !!hexNewsletterOptIn, env);
168+
} catch (error) {
169+
console.error("Failed to update Hex newsletter subscription:", error);
170+
}
152171
}
153-
}
154172

155-
if (!!existingUser.dvda_newsletter_opt_in !== !!dvdaNewsletterOptIn) {
156-
try {
157-
await subscribeToNewsletter(user?.email || "", env.DVDA_NEWSLETTER_ID || "", dvdaNewsletterOptIn ? 1 : 0, env);
158-
} catch (error) {
159-
console.error("Failed to update DVDA newsletter subscription:", error);
173+
if (!!existingUser.dvda_newsletter_opt_in !== !!dvdaNewsletterOptIn) {
174+
try {
175+
await subscribeToNewsletter(user.email, env.DVDA_NEWSLETTER_ID!, !!dvdaNewsletterOptIn, env);
176+
} catch (error) {
177+
console.error("Failed to update DVDA newsletter subscription:", error);
178+
}
160179
}
161180
}
162-
163-
if (!existingUser.accept_terms && acceptTerms !== true) {
164-
return errorResponse('You must accept the terms to use the service', 400);
165-
}
166181

167182
return json({
168183
id: user.id,
@@ -171,6 +186,7 @@ export const onRequestPatch: PagesFunction<Env> = async (context) => {
171186
avatarUrl: user.avatar_url,
172187
isAdmin: user.is_admin === 1,
173188
defaultEngineId: user.default_engine_id,
189+
defaultEditorEngineId: user.default_editor_engine_id,
174190
acceptTerms: user.accept_terms === 1,
175191
hexNewsletterOptIn: user.hex_newsletter_opt_in === 1,
176192
dvdaNewsletterOptIn: user.dvda_newsletter_opt_in === 1,

functions/api/auth/verify.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export const onRequestGet: PagesFunction<Env> = async (context) => {
4141

4242
// Find or create user
4343
let user = await env.DB.prepare(`
44-
SELECT id, email, display_name, avatar_url, is_admin, created_at, updated_at
44+
SELECT id, email, display_name, avatar_url, is_admin, created_at, updated_at, default_engine_id, default_editor_engine_id, accept_terms, hex_newsletter_opt_in, dvda_newsletter_opt_in
4545
FROM profiles
4646
WHERE email = ?
4747
`).bind(authToken.email).first<{
@@ -52,6 +52,11 @@ export const onRequestGet: PagesFunction<Env> = async (context) => {
5252
is_admin: number;
5353
created_at: string;
5454
updated_at: string;
55+
default_engine_id: string | null;
56+
default_editor_engine_id: string | null;
57+
accept_terms: number;
58+
hex_newsletter_opt_in: number;
59+
dvda_newsletter_opt_in: number;
5560
}>();
5661

5762
const isAdmin = isAdminEmail(authToken.email, env);
@@ -74,6 +79,11 @@ export const onRequestGet: PagesFunction<Env> = async (context) => {
7479
is_admin: isAdmin ? 1 : 0,
7580
created_at: new Date().toISOString(),
7681
updated_at: new Date().toISOString(),
82+
default_engine_id: null,
83+
default_editor_engine_id: null,
84+
accept_terms: 0,
85+
hex_newsletter_opt_in: 0,
86+
dvda_newsletter_opt_in: 0,
7787
};
7888
} else if (isAdmin && !user.is_admin) {
7989
// Update admin status if needed
@@ -111,6 +121,13 @@ export const onRequestGet: PagesFunction<Env> = async (context) => {
111121
displayName: user.display_name,
112122
avatarUrl: user.avatar_url,
113123
isAdmin: user.is_admin === 1,
124+
defaultEngineId: user.default_engine_id,
125+
defaultEditorEngineId: user.default_editor_engine_id,
126+
createdAt: user.created_at,
127+
updatedAt: user.updated_at,
128+
acceptTerms: user.accept_terms,
129+
hexNewsletterOptIn: user.hex_newsletter_opt_in,
130+
dvdaNewsletterOptIn: user.dvda_newsletter_opt_in,
114131
},
115132
accessToken,
116133
refreshToken,

0 commit comments

Comments
 (0)