Skip to content

Commit 891a4af

Browse files
committed
massive update
1 parent 3642a55 commit 891a4af

67 files changed

Lines changed: 4784 additions & 779 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.beads/issues.jsonl

Lines changed: 41 additions & 7 deletions
Large diffs are not rendered by default.

.opencode/agent/battle-mode.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,61 @@ Use for:
7171
- Combining Zero patterns with Svelte 5 runes
7272
- Any component in `src/lib/` that reads/writes Zero data
7373

74+
## Zero Data Flow
75+
76+
**Zero is the state of the app.** All synced data flows through Zero - never use
77+
local Svelte state (`$state`) for data that should be persisted or synced.
78+
79+
### The Pattern
80+
81+
```
82+
User Action
83+
→ Call mutator: z.mutate(mutators.table.update({ ... }))
84+
→ Zero syncs to server and other clients
85+
→ Zero query data updates reactively
86+
→ UI reads from query, updates automatically
87+
```
88+
89+
### Rules
90+
91+
1. **Read from Zero queries** - Use `z.createQuery()` to get data, pass it to
92+
components as props
93+
2. **Write via Zero mutators** - All data changes go through
94+
`z.mutate(mutators.*)`, never direct state updates
95+
3. **No local state for synced data** - Don't duplicate Zero data in `$state`.
96+
If you need derived values, use `$derived` from the query data
97+
4. **Components are "dumb"** - They receive data as props from queries, emit
98+
changes via mutators. Zero handles reactivity.
99+
100+
### Example: Diff Score
101+
102+
```svelte
103+
<!-- WRONG: Local state duplicates Zero data -->
104+
<script>
105+
let score = $state(null); // Don't do this!
106+
// ... compute score ...
107+
score = result; // Local state gets out of sync
108+
</script>
109+
110+
<!-- RIGHT: Read from Zero, write via mutator -->
111+
<script>
112+
let { hax } = $props(); // hax comes from Zero query
113+
114+
// Write: save to DB via mutator
115+
z.mutate(mutators.hax.update({ id: hax.id, diff_score: newScore }));
116+
117+
// Read: use query data directly
118+
// hax.diff_score updates automatically when Zero syncs
119+
</script>
120+
<DiffPreview score={hax.diff_score} />
121+
```
122+
123+
### When Local State IS Appropriate
124+
125+
- UI-only state (modal open/closed, form input before submit)
126+
- Ephemeral state (hover states, animation progress)
127+
- Derived computations from Zero data (use `$derived`)
128+
74129
## Core Concepts
75130

76131
### Battles
@@ -100,6 +155,29 @@ components. **Before writing any HTML or CSS**, check
100155
https://graffiti-ui.com/llms.txt for the proper Graffiti patterns and utility
101156
classes.
102157

158+
### ALWAYS use Graffiti for:
159+
160+
- **Layouts**: Use `class="cluster"`, `class="stack"`, `class="sidebar"`,
161+
`class="switcher"`, `class="grid"`, `class="layout-card"` etc.
162+
- **Spacing**: Use `--gap` custom properties instead of custom margin/padding
163+
- **Typography**: Use Graffiti type utilities
164+
- **Buttons/Forms**: Use existing Graffiti button classes like `button`,
165+
`go_button`, `big_button`
166+
167+
### AVOID writing custom CSS for:
168+
169+
- Flexbox layouts (use `cluster`, `stack`, `sidebar` instead)
170+
- Grid layouts (use `grid`, `layout-card` instead)
171+
- Basic spacing (use `--gap` props or Graffiti spacing)
172+
- Common UI patterns that Graffiti already handles
173+
174+
### Only write custom CSS when:
175+
176+
- Graffiti doesn't have an equivalent utility
177+
- Component-specific styling that can't be achieved with utilities
178+
- Animation/transition effects
179+
- Very specific visual design requirements
180+
103181
## Svelte 5 Rules
104182

105183
Always use modern Svelte 5 syntax:

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"@tanstack/match-sorter-utils": "^8.19.4",
5757
"@tanstack/svelte-table": "npm:tanstack-table-8-svelte-5@^0.1",
5858
"@tanstack/table-core": "^8.21.3",
59+
"@zumer/snapdom": "^2.0.1",
5960
"arktype": "^2.1.29",
6061
"better-auth": "^1.4.10",
6162
"dotenv": "^17.2.3",

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<!doctype html>
2-
<html lang="en">
2+
<html lang="en" class="dark">
33
<head>
44
<meta charset="utf-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1" />

src/db/schema.ts

Lines changed: 128 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,29 @@ import {
1515
/* =========================
1616
Enums
1717
========================= */
18-
export const relationship_type_enum = pgEnum('relationship_type', ['FOLLOW', 'FRIEND']);
19-
export const target_type_enum = pgEnum('target_type', ['CODE', 'IMAGE', 'VIDEO']);
20-
export const battle_status_enum = pgEnum('battle_status', ['PENDING', 'ACTIVE', 'COMPLETED']);
18+
export const relationship_type_enum = pgEnum('relationship_type', [
19+
'FOLLOW',
20+
'FRIEND'
21+
]);
22+
export const target_type_enum = pgEnum('target_type', [
23+
'CODE',
24+
'IMAGE',
25+
'VIDEO'
26+
]);
27+
export const battle_status_enum = pgEnum('battle_status', [
28+
'PENDING',
29+
'ACTIVE',
30+
'COMPLETED'
31+
]);
2132
export const visibility_enum = pgEnum('visibility', ['PUBLIC', 'PRIVATE']);
22-
export const battle_type_enum = pgEnum('battle_type', ['TIME_TRIAL', 'TIMED_MATCH']);
33+
export const battle_type_enum = pgEnum('battle_type', [
34+
'TIME_TRIAL',
35+
'TIMED_MATCH'
36+
]);
37+
export const win_condition_enum = pgEnum('win_condition', [
38+
'VOTING', // Traditional: battle ends, then voting determines winner
39+
'FIRST_TO_PERFECT' // Race: first to 100% diff_score wins instantly
40+
]);
2341
export const participant_status_enum = pgEnum('participant_status', [
2442
'PENDING',
2543
'READY',
@@ -28,8 +46,16 @@ export const participant_status_enum = pgEnum('participant_status', [
2846
'FINISHED'
2947
]);
3048

31-
export const user_award_enum = pgEnum('user_award', ['MOST_ACCURATE', 'REAL_WORLD', 'BEST_FEEL']);
32-
export const award_outcome_enum = pgEnum('award_outcome', ['AWARDED', 'VOID_TIE', 'VOID_OTHER']);
49+
export const user_award_enum = pgEnum('user_award', [
50+
'MOST_ACCURATE',
51+
'REAL_WORLD',
52+
'BEST_FEEL'
53+
]);
54+
export const award_outcome_enum = pgEnum('award_outcome', [
55+
'AWARDED',
56+
'VOID_TIE',
57+
'VOID_OTHER'
58+
]);
3359
export const hax_type_enum = pgEnum('hax_type', ['BATTLE', 'SOLO']);
3460

3561
/* =========================
@@ -38,6 +64,7 @@ export const hax_type_enum = pgEnum('hax_type', ['BATTLE', 'SOLO']);
3864
export const user = pgTable('user', {
3965
id: text('id').primaryKey(),
4066
name: text().notNull(),
67+
username: text(),
4168
email: text().notNull().unique(),
4269
emailVerified: boolean('email_verified')
4370
.$defaultFn(() => false)
@@ -96,8 +123,12 @@ export const verification = pgTable('verification', {
96123
identifier: text('identifier').notNull(),
97124
value: text('value').notNull(),
98125
expiresAt: timestamp('expires_at').notNull(),
99-
createdAt: timestamp('created_at').$defaultFn(() => /* @__PURE__ */ new Date()),
100-
updatedAt: timestamp('updated_at').$defaultFn(() => /* @__PURE__ */ new Date())
126+
createdAt: timestamp('created_at').$defaultFn(
127+
() => /* @__PURE__ */ new Date()
128+
),
129+
updatedAt: timestamp('updated_at').$defaultFn(
130+
() => /* @__PURE__ */ new Date()
131+
)
101132
});
102133

103134
/* =========================
@@ -114,11 +145,20 @@ export const user_relationships = pgTable(
114145
.notNull()
115146
.references(() => user.id, { onDelete: 'cascade' }),
116147
type: relationship_type_enum('type').notNull(),
117-
created_at: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
148+
created_at: timestamp('created_at', { withTimezone: true })
149+
.notNull()
150+
.defaultNow()
118151
},
119152
(t) => [
120-
uniqueIndex('user_relationships_unique').on(t.user_id, t.related_user_id, t.type),
121-
check('user_relationships_no_self', sql`${t.user_id} <> ${t.related_user_id}`)
153+
uniqueIndex('user_relationships_unique').on(
154+
t.user_id,
155+
t.related_user_id,
156+
t.type
157+
),
158+
check(
159+
'user_relationships_no_self',
160+
sql`${t.user_id} <> ${t.related_user_id}`
161+
)
122162
]
123163
);
124164

@@ -166,11 +206,15 @@ export const battles = pgTable(
166206
visibility: visibility_enum().notNull().default('PRIVATE'),
167207
zero_room_id: text().notNull(),
168208
type: battle_type_enum().notNull(),
209+
win_condition: win_condition_enum().notNull().default('FIRST_TO_PERFECT'),
169210
total_time_seconds: integer().notNull(),
170211
overtime_seconds: integer(),
171212
starts_at: timestamp({ withTimezone: true }),
172213
ends_at: timestamp({ withTimezone: true }),
214+
allow_time_extension: boolean().notNull().default(true),
173215
revealed_at: timestamp({ withTimezone: true }),
216+
// Winner tracking (for FIRST_TO_PERFECT mode) - stores the hax.id of the winner
217+
winner_hax_id: uuid(),
174218
created_at: timestamp({ withTimezone: true }).notNull().defaultNow(),
175219
updated_at: timestamp({ withTimezone: true }).notNull().defaultNow()
176220
},
@@ -229,6 +273,9 @@ export const hax = pgTable(
229273
submission_locked_at: timestamp({ withTimezone: true }),
230274
is_final: boolean().notNull().default(false),
231275
rendered_preview_url: text(),
276+
// Live diff score (0-100) comparing user output to target
277+
diff_score: integer(),
278+
diff_score_updated_at: timestamp({ withTimezone: true }),
232279
created_at: timestamp({ withTimezone: true }).notNull().defaultNow(),
233280
updated_at: timestamp({ withTimezone: true }).notNull().defaultNow()
234281
},
@@ -248,6 +295,31 @@ export const hax_unique_user_target_solo = sql`
248295
where "type" = 'SOLO';
249296
`;
250297

298+
/* =========================
299+
HAX_HISTORY (for playback/replay)
300+
========================= */
301+
export const hax_history = pgTable(
302+
'hax_history',
303+
{
304+
id: uuid().primaryKey().defaultRandom(),
305+
hax_id: uuid()
306+
.notNull()
307+
.references(() => hax.id, { onDelete: 'cascade' }),
308+
html: text().notNull(),
309+
css: text().notNull(),
310+
// Milliseconds since battle started - for playback timing
311+
elapsed_ms: integer().notNull(),
312+
// Sequence number for ordering saves within a hax
313+
sequence: integer().notNull(),
314+
created_at: timestamp({ withTimezone: true }).notNull().defaultNow()
315+
},
316+
(t) => [
317+
index('hax_history_by_hax').on(t.hax_id),
318+
// Composite index for efficient playback queries (ordered by sequence)
319+
index('hax_history_playback').on(t.hax_id, t.sequence)
320+
]
321+
);
322+
251323
/* =========================
252324
RATINGS
253325
========================= */
@@ -333,7 +405,10 @@ export const awards = pgTable(
333405
updated_at: timestamp({ withTimezone: true }).notNull().defaultNow()
334406
},
335407
(t) => [
336-
uniqueIndex('awards_unique_battle_award_type').on(t.battle_id, t.award_type),
408+
uniqueIndex('awards_unique_battle_award_type').on(
409+
t.battle_id,
410+
t.award_type
411+
),
337412
index('awards_user_idx').on(t.user_id),
338413
index('awards_battle_idx').on(t.battle_id)
339414
]
@@ -382,18 +457,21 @@ export const user_relations = relations(user, ({ many }) => ({
382457
}));
383458

384459
// UserRelationships → User
385-
export const user_relationships_relations = relations(user_relationships, ({ one }) => ({
386-
user: one(user, {
387-
fields: [user_relationships.user_id],
388-
references: [user.id],
389-
relationName: 'fromUser'
390-
}),
391-
relatedUser: one(user, {
392-
fields: [user_relationships.related_user_id],
393-
references: [user.id],
394-
relationName: 'toUser'
460+
export const user_relationships_relations = relations(
461+
user_relationships,
462+
({ one }) => ({
463+
user: one(user, {
464+
fields: [user_relationships.user_id],
465+
references: [user.id],
466+
relationName: 'fromUser'
467+
}),
468+
relatedUser: one(user, {
469+
fields: [user_relationships.related_user_id],
470+
references: [user.id],
471+
relationName: 'toUser'
472+
})
395473
})
396-
}));
474+
);
397475

398476
// Battles → Target and Referee (User)
399477
export const battle_relations = relations(battles, ({ one, many }) => ({
@@ -410,22 +488,25 @@ export const battle_relations = relations(battles, ({ one, many }) => ({
410488
}));
411489

412490
// BattleParticipants → Battle + User + Hax
413-
export const battle_participants_relations = relations(battle_participants, ({ one }) => ({
414-
battle: one(battles, {
415-
fields: [battle_participants.battle_id],
416-
references: [battles.id]
417-
}),
418-
user: one(user, {
419-
fields: [battle_participants.user_id],
420-
references: [user.id]
421-
}),
422-
hax: one(hax, {
423-
fields: [battle_participants.user_id, battle_participants.battle_id],
424-
references: [hax.user_id, hax.battle_id]
491+
export const battle_participants_relations = relations(
492+
battle_participants,
493+
({ one }) => ({
494+
battle: one(battles, {
495+
fields: [battle_participants.battle_id],
496+
references: [battles.id]
497+
}),
498+
user: one(user, {
499+
fields: [battle_participants.user_id],
500+
references: [user.id]
501+
}),
502+
hax: one(hax, {
503+
fields: [battle_participants.user_id, battle_participants.battle_id],
504+
references: [hax.user_id, hax.battle_id]
505+
})
425506
})
426-
}));
507+
);
427508

428-
// Hax → User, Battle, Target, Votes
509+
// Hax → User, Battle, Target, Votes, History
429510
export const hax_relations = relations(hax, ({ one, many }) => ({
430511
user: one(user, {
431512
fields: [hax.user_id],
@@ -439,7 +520,16 @@ export const hax_relations = relations(hax, ({ one, many }) => ({
439520
fields: [hax.target_id],
440521
references: [targets.id]
441522
}),
442-
votes: many(battle_votes)
523+
votes: many(battle_votes),
524+
history: many(hax_history)
525+
}));
526+
527+
// HaxHistory → Hax
528+
export const hax_history_relations = relations(hax_history, ({ one }) => ({
529+
hax: one(hax, {
530+
fields: [hax_history.hax_id],
531+
references: [hax.id]
532+
})
443533
}));
444534

445535
// --- Ratings relations

0 commit comments

Comments
 (0)