Skip to content

Commit edb4a17

Browse files
ekzyishuumn
andauthored
Halloween (#2614)
* Halloween db schema * Show biohazard icon if infected * Infect users on zap * Users are immune if item younger than 8 hours exists * @anon as patient zero * Fix missing cache update on custom zap * Add notification if infected * Change immunity duration to 6 hours * Add push notification if infected * Infect inactive users after 36 hours * Decrease immunity by 1 hour every day * Fix custom zap without QR code * Crop SVG and fix alignment cropped with https://svgcrop.com/ --------- Co-authored-by: Keyan <[email protected]>
1 parent 8c0bfe9 commit edb4a17

File tree

23 files changed

+284
-10
lines changed

23 files changed

+284
-10
lines changed

api/paidAction/zap.js

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
1+
import { HALLOWEEN_IMMUNITY_HOURS, PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
22
import { msatsToSats, satsToMsats } from '@/lib/format'
3-
import { notifyZapped } from '@/lib/webPush'
3+
import { datePivot } from '@/lib/time'
4+
import { notifyZapped, notifyInfected } from '@/lib/webPush'
45
import { getInvoiceableWallets } from '@/wallets/server'
56
import { Prisma } from '@prisma/client'
67

@@ -78,9 +79,12 @@ export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, c
7879
]
7980
})
8081

81-
const [{ path }] = await tx.$queryRaw`
82-
SELECT ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER`
83-
return { id: itemId, sats, act: 'TIP', path, actIds: acts.map(act => act.id) }
82+
const [{ userId, path }] = await tx.$queryRaw`
83+
SELECT "userId", ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER`
84+
85+
const immune = await isImmune(userId, { tx })
86+
87+
return { id: itemId, sats, act: 'TIP', path, immune, actIds: acts.map(act => act.id) }
8488
}
8589

8690
export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) {
@@ -216,6 +220,55 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
216220
SET "bountyPaidTo" = array_remove(array_append(array_remove("bountyPaidTo", bounty.target), bounty.target), NULL)
217221
FROM bounty
218222
WHERE "Item".id = bounty.id AND bounty.paid`
223+
224+
await maybeInfectUser(itemAct, { tx })
225+
}
226+
227+
async function isImmune (userId, { tx }) {
228+
// immunity lasts one hour less every day until a minimum of 1 hour is reached
229+
const difficulty = Math.max(1, HALLOWEEN_IMMUNITY_HOURS - daysSinceHalloween())
230+
const item = await tx.item.findFirst({
231+
where: {
232+
userId,
233+
createdAt: { gt: datePivot(new Date(), { hours: -difficulty }) }
234+
}
235+
})
236+
return !!item
237+
}
238+
239+
function daysSinceHalloween () {
240+
// return 0 if Halloween has not happened yet else return the days since Halloween
241+
const diffTime = new Date().getTime() - new Date('2025-10-31').getTime()
242+
return Math.max(0, Math.floor(diffTime / (1000 * 60 * 60 * 24)))
243+
}
244+
245+
async function maybeInfectUser (itemAct, { tx }) {
246+
// We added the 'infected' column to the users table so the query for users can continue
247+
// to only fetch columns from the users table. We only use it for display purposes.
248+
// The infection table is used to check if a user is infected and store additional information
249+
// (who infected who when why).
250+
251+
const { id, userId: fromId, item: { userId: toId } } = itemAct
252+
const infection = await tx.infection.findFirst({ where: { infecteeId: fromId } })
253+
if (!infection) {
254+
// zapper not infected, so can't infect other user
255+
return
256+
}
257+
258+
if (await isImmune(toId, { tx })) {
259+
// user is immune because they created an item not too long ago
260+
return
261+
}
262+
263+
const count = await tx.$executeRaw`
264+
INSERT INTO "Infection" ("itemActId", "infecteeId", "infectorId")
265+
VALUES (${id}::INTEGER, ${toId}::INTEGER, ${fromId}::INTEGER)
266+
ON CONFLICT ("infecteeId") DO NOTHING`
267+
await tx.user.update({ where: { id: toId }, data: { infected: true } })
268+
269+
if (count > 0) {
270+
notifyInfected(toId).catch(console.error)
271+
}
219272
}
220273

221274
export async function nonCriticalSideEffects ({ invoice, actIds }, { models }) {

api/resolvers/notifications.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,14 @@ export default {
325325
ORDER BY "sortTime" DESC
326326
LIMIT ${LIMIT})`
327327
)
328+
queries.push(
329+
`(SELECT id::text, created_at AS "sortTime", 0 as "earnedSats", 'Infection' AS type
330+
FROM "Infection"
331+
WHERE "infecteeId" = $1
332+
AND created_at < $2
333+
ORDER BY "sortTime" DESC
334+
LIMIT ${LIMIT})`
335+
)
328336
for (const type of ['HORSE', 'GUN']) {
329337
const gqlType = type.charAt(0) + type.slice(1).toLowerCase()
330338
queries.push(

api/resolvers/user.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,19 @@ export default {
506506
foundNotes()
507507
return true
508508
}
509+
510+
const infection = await models.infection.findFirst({
511+
where: {
512+
infecteeId: me.id,
513+
createdAt: {
514+
gt: lastChecked
515+
}
516+
}
517+
})
518+
if (infection) {
519+
foundNotes()
520+
return true
521+
}
509522
}
510523

511524
const subStatus = await models.sub.findFirst({

api/typeDefs/item.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export default gql`
3232
sats: Int!
3333
path: String
3434
act: String!
35+
immune: Boolean
3536
}
3637
3738
type ItemAct {

api/typeDefs/notifications.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,11 +175,17 @@ export default gql`
175175
sortTime: Date!
176176
}
177177
178+
type Infection {
179+
id: ID!
180+
sortTime: Date!
181+
}
182+
178183
union Notification = Reply | Votification | Mention
179184
| Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral
180185
| FollowActivity | ForwardedVotification | Revenue | SubStatus
181186
| TerritoryPost | TerritoryTransfer | Reminder | ItemMention | Invoicification
182187
| ReferralReward | CowboyHat | NewHorse | LostHorse | NewGun | LostGun
188+
| Infection
183189
184190
type Notifications {
185191
lastChecked: Date

api/typeDefs/user.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ export default gql`
217217
streak: Int
218218
gunStreak: Int
219219
horseStreak: Int
220+
infected: Boolean
220221
hasSendWallet: Boolean
221222
hasRecvWallet: Boolean
222223
hideWalletRecvPrompt: Boolean

components/badge.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import AnonIcon from '@/svgs/spy-fill.svg'
66
import GunIcon from '@/svgs/revolver.svg'
77
import HorseIcon from '@/svgs/horse.svg'
88
import BotIcon from '@/svgs/robot-2-fill.svg'
9+
import BioHazardIcon from '@/svgs/biohazard.svg'
910
import { numWithUnits } from '@/lib/format'
1011
import { USER_ID } from '@/lib/constants'
1112
import classNames from 'classnames'
@@ -47,6 +48,13 @@ export default function Badges ({ user, badge, bot, className = 'ms-1', badgeCla
4748
})
4849
}
4950

51+
if (user.optional.infected) {
52+
badges.push({
53+
icon: BioHazardIcon,
54+
overlayText: 'infected'
55+
})
56+
}
57+
5058
if (bot) {
5159
badges = [{
5260
icon: BotIcon,

components/item-act.js

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Button from 'react-bootstrap/Button'
22
import InputGroup from 'react-bootstrap/InputGroup'
33
import React, { useState, useRef, useEffect, useCallback } from 'react'
4+
import gql from 'graphql-tag'
45
import { Form, Input, SubmitButton } from './form'
56
import { useMe } from './me'
67
import UpBolt from '@/svgs/bolt.svg'
@@ -110,10 +111,11 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
110111
}
111112
}
112113

113-
const onPaid = () => {
114+
const onPaid = (cache, { data } = {}) => {
114115
animate()
115116
onClose?.()
116117
if (!me) setItemMeAnonSats({ id: item.id, amount })
118+
if (cache && data) infectOnPaid(cache, { me, data })
117119
}
118120

119121
const closeImmediately = hasSendWallet || me?.privates?.sats > Number(amount)
@@ -133,13 +135,16 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
133135
act: {
134136
__typename: 'ItemActPaidAction',
135137
result: {
136-
id: item.id, sats: Number(amount), act, path: item.path
138+
id: item.id, sats: Number(amount), immune: true, act, path: item.path
137139
}
138140
}
139141
}
140142
: undefined,
141143
// don't close modal immediately because we want the QR modal to stack
142-
onPaid: closeImmediately ? undefined : onPaid
144+
// but still trigger halloween infection
145+
onPaid: closeImmediately
146+
? (cache, { data }) => infectOnPaid(cache, { me, data })
147+
: onPaid
143148
})
144149
if (error) throw error
145150
addCustomTip(Number(amount))
@@ -292,6 +297,7 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
292297
if (!response) return
293298
updateAncestors(cache, response)
294299
options?.onPaid?.(cache, { data })
300+
infectOnPaid(cache, { data, me })
295301
}
296302
})
297303
return act
@@ -310,7 +316,7 @@ export function useZap () {
310316
const sats = nextTip(meSats, { ...me?.privates })
311317

312318
const variables = { id: item.id, sats, act: 'TIP', hasSendWallet }
313-
const optimisticResponse = { act: { __typename: 'ItemActPaidAction', result: { path: item.path, ...variables } } }
319+
const optimisticResponse = { act: { __typename: 'ItemActPaidAction', result: { path: item.path, immune: true, ...variables } } }
314320

315321
try {
316322
await abortSignal.pause({ me, amount: sats })
@@ -372,3 +378,34 @@ const zapUndo = async (signal, amount) => {
372378
}, ZAP_UNDO_DELAY_MS)
373379
})
374380
}
381+
382+
const infectOnPaid = (cache, { data, me }) => {
383+
const { act: { result } } = data
384+
385+
// anon is patient zero and therefore always infected
386+
const infected = !me || me.optional.infected
387+
if (!infected || result.immune) {
388+
return
389+
}
390+
391+
const itemId = Number(result.path.split('.').pop())
392+
const item = cache.readFragment({
393+
id: `Item:${itemId}`,
394+
fragment: gql`
395+
fragment InfectOnPaidItemFields on Item {
396+
user {
397+
id
398+
}
399+
}`
400+
})
401+
cache.writeFragment({
402+
id: `User:${item.user.id}`,
403+
fragment: gql`
404+
fragment InfectOnPaidUserFields on User {
405+
optional {
406+
infected
407+
}
408+
}`,
409+
data: { optional: { infected: true } }
410+
})
411+
}

components/notifications.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import HolsterIcon from '@/svgs/holster.svg'
4545
import SaddleIcon from '@/svgs/saddle.svg'
4646
import CCInfo from './info/cc'
4747
import { useMe } from './me'
48+
import BioHazardIcon from '@/svgs/biohazard.svg'
4849

4950
function Notification ({ n, fresh }) {
5051
const type = n.__typename
@@ -61,6 +62,7 @@ function Notification ({ n, fresh }) {
6162
(type === 'CowboyHat' && <CowboyHat n={n} />) ||
6263
(['NewHorse', 'LostHorse'].includes(type) && <Horse n={n} />) ||
6364
(['NewGun', 'LostGun'].includes(type) && <Gun n={n} />) ||
65+
(type === 'Infection' && <Infection n={n} />) ||
6466
(type === 'Votification' && <Votification n={n} />) ||
6567
(type === 'ForwardedVotification' && <ForwardedVotification n={n} />) ||
6668
(type === 'Mention' && <Mention n={n} />) ||
@@ -167,7 +169,7 @@ const defaultOnClick = n => {
167169
if (type === 'WithdrawlPaid') return { href: `/withdrawals/${n.id}` }
168170
if (type === 'Referral') return { href: '/referrals/month' }
169171
if (type === 'ReferralReward') return { href: '/referrals/month' }
170-
if (['CowboyHat', 'NewHorse', 'LostHorse', 'NewGun', 'LostGun'].includes(type)) return {}
172+
if (['CowboyHat', 'NewHorse', 'LostHorse', 'NewGun', 'LostGun', 'Infection'].includes(type)) return {}
171173
if (type === 'TerritoryTransfer') return { href: `/~${n.sub.name}` }
172174

173175
if (!n.item) return {}
@@ -240,6 +242,22 @@ function Gun ({ n }) {
240242
)
241243
}
242244

245+
function Infection ({ n }) {
246+
// TODO: use random blurbs?
247+
return (
248+
<div className='d-flex'>
249+
<BioHazardIcon className='fill-grey mx-1' width={40} height={40} />
250+
<div className='ms-2'>
251+
<NoteHeader big>
252+
you have been bitten by a zombie
253+
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
254+
</NoteHeader>
255+
<div><small style={{ lineHeight: '140%', display: 'inline-block' }}>You're feeling cold and clammy and you have a sudden desire to zap everyone ...</small></div>
256+
</div>
257+
</div>
258+
)
259+
}
260+
243261
function EarnNotification ({ n }) {
244262
const time = n.minSortTime === n.sortTime ? dayMonthYear(new Date(n.minSortTime)) : `${dayMonthYear(new Date(n.minSortTime))} to ${dayMonthYear(new Date(n.sortTime))}`
245263

fragments/comments.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const STREAK_FIELDS = gql`
77
streak
88
hasSendWallet
99
hasRecvWallet
10+
infected
1011
}
1112
}
1213
`

0 commit comments

Comments
 (0)