Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/econ/migrations/0001_reward_selection.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- Game-reward selections — the three-choice reward the client shows after a
-- challenge or level-up. `/api/gamerewards/v1/request` mints one and pushes it to the
-- player over the notifications hub; `/api/gamerewards/v1/select` consumes it.
--
-- The three offered drop ids are recorded so `select` can verify the player is
-- claiming something they were actually offered, and `consumed` makes the selection
-- single-use. Owned by the `econ` worker; generated from src/rewards-db.ts
-- (SCHEMA_DDL) — keep in sync.

CREATE TABLE IF NOT EXISTS reward_selection (
reward_selection_id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
message TEXT NOT NULL DEFAULT '',
gift_context INTEGER NOT NULL DEFAULT 0,
reward_type INTEGER NOT NULL DEFAULT 0,
gift_drop_1_id INTEGER NOT NULL,
gift_drop_2_id INTEGER NOT NULL,
gift_drop_3_id INTEGER NOT NULL,
consumed INTEGER NOT NULL DEFAULT 0,
created_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_reward_selection_account ON reward_selection (account_id);
22 changes: 22 additions & 0 deletions apps/econ/migrations/0002_objective.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- Per-player objective progress — the daily/weekly challenge checklist. The client
-- reports progress with `/api/objectives/v1/updateobjective` and reads it back from
-- `/api/objectives/v1/myprogress`.
--
-- An objective is keyed by (account, group, index) — the client's own identifiers —
-- so updates upsert on that triple. `has_claimed_reward` latches on first completion
-- so a reward can't be paid twice. `group`/`index` are SQL keywords, hence the
-- `group_id`/`idx` column names. Owned by the `econ` worker; generated from
-- src/objectives-db.ts (SCHEMA_DDL) — keep in sync.

CREATE TABLE IF NOT EXISTS objective (
account_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
idx INTEGER NOT NULL,
progress REAL NOT NULL DEFAULT 0,
visual_progress REAL NOT NULL DEFAULT 0,
is_completed INTEGER NOT NULL DEFAULT 0,
is_rewarded INTEGER NOT NULL DEFAULT 0,
has_claimed_reward INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (account_id, group_id, idx)
);
CREATE INDEX IF NOT EXISTS idx_objective_account ON objective (account_id);
16 changes: 16 additions & 0 deletions apps/econ/migrations/0003_objective_group.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- A player's objective *groups* — the daily/weekly sets their objectives belong to.
-- The client clears a group when it's finished with it (`/api/objectives/v1/cleargroup`),
-- which marks it completed and stamps the clear time; `myprogress` reads the groups
-- back alongside the objectives themselves.
--
-- Keyed by (account, group), the client's own identifier. `group` is a SQL keyword,
-- hence `group_id`. Owned by the `econ` worker; generated from src/objectives-db.ts
-- (SCHEMA_DDL) — keep in sync.

CREATE TABLE IF NOT EXISTS objective_group (
account_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
is_completed INTEGER NOT NULL DEFAULT 0,
cleared_at TEXT,
PRIMARY KEY (account_id, group_id)
);
1 change: 1 addition & 0 deletions apps/econ/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"check:types": "run-tsc",
"check:workers-types": "run-wrangler-types --check",
"deploy": "run-wrangler-deploy",
"migrate": "run-wrangler-migrate",
"dev": "run-wrangler-dev",
"fix:workers-types": "run-wrangler-types",
"test": "run-vitest"
Expand Down
7 changes: 6 additions & 1 deletion apps/econ/src/context.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
// Type-only import (erased at build) of the DO class owned by the `notify` worker, so
// the RPC methods on the hub binding are typed here.
import type { NotificationsHub } from '../../notify/src/notifications-hub'

export type Env = SharedHonoEnv & {
// Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value
// with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens
// signed by `auth` verify here.
JWT_SECRET: SecretsStoreSecret
/** Shared `recflare` D1 (accounts table) — stores the player's avatar. */
/** Shared `recflare` D1: the accounts table (avatar) + this worker's reward_selection. */
DB: D1Database
/** Static storefront catalogs (`static/storefronts/sf*.json`), fetched by path. */
ASSETS: Fetcher
/** Notifications hub (DO in `notify`) — game rewards are pushed over the websocket. */
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
}

/** Variables can be extended */
Expand Down
215 changes: 210 additions & 5 deletions apps/econ/src/econ.app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Hono } from 'hono'
import { useWorkersLogger } from 'workers-tagged-logger'
import { useWorkersLogger, WorkersLogger } from 'workers-tagged-logger'

import { withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
Expand All @@ -9,6 +9,19 @@ import defaultAvatar from '../static/default-avatar.json'
import myProgress from '../static/my-progress.json'
import weeklyChallenge from '../static/weekly-challenge.json'
import { getAvatar, setAvatar } from './avatar-db'
import {
clearObjectiveGroup,
getObjectiveGroups,
getObjectives,
updateObjective,
} from './objectives-db'
import {
consumeRewardSelection,
createRewardSelection,
getRewardSelection,
rollRewardDrops,
tokenRewardDrop,
} from './rewards-db'

import type { Context } from 'hono'
import type { Avatar } from './avatar-db'
Expand All @@ -35,6 +48,37 @@ function unauthorized(c: Context<App>) {
return c.body(null, 401)
}

const logger = new WorkersLogger()

/** The single hub Durable Object every worker talks to. */
const HUB_INSTANCE = 'global'

/**
* Push a notification to a player over the websocket hub. Rewards are *delivered*
* this way — the HTTP response carries none of it — but a hub that's down shouldn't
* fail the request that already committed, so a delivery failure is logged, not thrown.
*/
async function pushToPlayer(
c: Context<App>,
playerId: number,
notificationType: string,
data: Record<string, unknown>
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
playerId,
notificationType,
data
)
} catch (err) {
logger.error('failed to push notification', {
playerId,
notificationType,
error: err instanceof Error ? err.message : String(err),
})
}
}

/**
* Project a stored avatar into the public render subset returned by
* `GET /api/avatar/v2/:id` — the fields needed to draw another player's avatar
Expand Down Expand Up @@ -101,10 +145,65 @@ const app = new Hono<App>()
return c.json({ Results: [], TotalResults: 0 })
})

// The player's objectives progress. Serves a static JSON file verbatim with
// no auth — same default for everyone until there's a DB binding to track
// per-player progress.
.get('/api/objectives/v1/myprogress', (c) => c.json(myProgress))
// The player's objectives progress. Their own recorded objectives once they've made
// any (the client reports them through `updateobjective`); the bundled default set
// otherwise, including for a signed-out caller — the client needs a well-formed
// checklist to render either way.
.get('/api/objectives/v1/myprogress', async (c) => {
const id = await authedId(c)
if (id === null) return c.json(myProgress)

const [objectives, groups] = await Promise.all([
getObjectives(c.env.DB, id),
getObjectiveGroups(c.env.DB, id),
])
if (objectives.length === 0 && groups.length === 0) return c.json(myProgress)
return c.json({
Objectives: objectives,
// Fall back to the default groups until the player has cleared one of their own.
ObjectiveGroups: groups.length === 0 ? myProgress.ObjectiveGroups : groups,
})
})

// The client clearing an objective group — it's done with that set (its dailies
// rolled over, say). Auth-gated; the JSON body carries `Group`. Marks the group
// completed, stamps the clear time, and returns the group as the client reads it.
.post('/api/objectives/v1/cleargroup', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)

const body = (await c.req.json().catch(() => ({}))) as Record<string, unknown>
const group = typeof body.Group === 'number' ? body.Group : 0

return c.json(await clearObjectiveGroup(c.env.DB, id, group))
})

// The client reporting progress on an objective as it plays. Auth-gated; the body is
// JSON (Group/Index identify the objective within the player's set). Answers a bare
// 200 — the client doesn't read anything back.
.post('/api/objectives/v1/updateobjective', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)

const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.body(null, 400)

const num = (v: unknown): number => (typeof v === 'number' ? v : 0)
const bool = (v: unknown): boolean => v === true

await updateObjective(c.env.DB, id, {
Group: num(body.Group),
Index: num(body.Index),
Progress: num(body.Progress),
VisualProgress: num(body.VisualProgress),
IsCompleted: bool(body.IsCompleted),
IsRewarded: bool(body.IsRewarded),
})
// The reference awards progression XP the first time an objective completes; we
// have no XP store yet, so completion is only recorded (HasClaimedReward latches
// so the award can't be double-paid once there is one).
return c.body(null, 200)
})

// The player's avatar, stored as a JSON blob on their account row. Falls back
// to the default outfit when they haven't saved one — the client's parser NREs
Expand Down Expand Up @@ -225,6 +324,112 @@ const app = new Hono<App>()
// Pending game rewards. Returns "[]".
.get('/api/gamerewards/v1/pending', (c) => c.json([]))

// Ask for a reward (a challenge completed, a level gained, …). The HTTP response
// carries *nothing* — the three choices are pushed to the player over the
// notifications hub as `RewardSelectionReceived`, and they pick one with
// `v1/select`. Auth-gated; answers the `{ error, success, value }` envelope.
.post('/api/gamerewards/v1/request', async (c) => {
const accountId = await authedId(c)
if (accountId === null) return unauthorized(c)

const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const field = (...names: string[]): string => {
const key = Object.keys(body).find((k) =>
names.some((n) => n.toLowerCase() === k.toLowerCase())
)
const v = key === undefined ? undefined : body[key]
return typeof v === 'string' ? v : ''
}
const message = field('Message')
const giftContext = Number.parseInt(field('giftContext', 'GiftContext'), 10) || 0
const rewardType = Number.parseInt(field('rewardType', 'RewardType'), 10) || 0

// No reward-drop catalog yet, so all three choices are token drops — the
// reference's own fallback when it runs out of drops for a context.
const drops = rollRewardDrops(giftContext)
const selection = await createRewardSelection(c.env.DB, accountId, {
message,
giftContext,
rewardType,
dropIds: drops.map((d) => d.GiftDropId),
})

await pushToPlayer(c, accountId, 'RewardSelectionReceived', {
RewardSelectionId: selection.RewardSelectionId,
Message: message,
GiftContext: giftContext,
RewardType: rewardType,
GiftDrop1: drops[0],
GiftDrop2: drops[1],
GiftDrop3: drops[2],
CreatedAt: selection.CreatedAt,
PlayerId: 0,
})

return c.json({ error: '', success: true, value: null })
})

// Claim one of the three rewards a selection offered. The selection must be the
// caller's, unconsumed, and actually contain the claimed drop — otherwise 403, so a
// player can't mint a reward they were never offered or redeem one twice. Returns
// the claimed drop, and pushes the resulting gift over the hub.
.post('/api/gamerewards/v1/select', async (c) => {
const accountId = await authedId(c)
if (accountId === null) return unauthorized(c)

const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const int = (name: string): number => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
const v = key === undefined ? undefined : body[key]
return typeof v === 'string' ? Number.parseInt(v, 10) || 0 : 0
}
const rewardSelectionId = int('rewardSelectionId')
const giftDropId = int('giftDropId')
if (giftDropId === 0) return c.json({ error: 'giftDropId is required' }, 400)

const selection =
rewardSelectionId <= 0 ? null : await getRewardSelection(c.env.DB, rewardSelectionId)
if (
selection === null ||
selection.AccountId !== accountId ||
selection.Consumed ||
!selection.GiftDropIds.includes(giftDropId)
) {
return c.body(null, 403)
}
// Consume conditionally: two racing claims mean the second one loses.
if (!(await consumeRewardSelection(c.env.DB, selection.RewardSelectionId))) {
return c.body(null, 403)
}

// Every drop is a token drop for now, and a token drop's id is the negative of
// its amount — so the claim rebuilds without a catalog lookup.
const drop = tokenRewardDrop(-giftDropId, selection.GiftContext)

await pushToPlayer(c, accountId, 'GiftPackageRewardSelectionReceived', {
Id: selection.RewardSelectionId,
FromGiftDropId: drop.GiftDropId,
FromPlayerId: 1,
ConsumableItemDesc: drop.ConsumableItemDesc,
AvatarItemDesc: drop.AvatarItemDesc,
EquipmentPrefabName: drop.EquipmentPrefabName,
EquipmentModificationGuid: drop.EquipmentModificationGuid,
CurrencyType: drop.CurrencyType,
Currency: drop.Currency,
Xp: 0,
Level: 0,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: -2,
GiftContext: selection.GiftContext,
GiftRarity: drop.Rarity,
Message: selection.Message,
AvatarItemType: drop.AvatarItemType,
})

return c.json(drop)
})

// The player's room keys. Returns "[]".
.get('/api/roomkeys/v1/mine', (c) => c.json([]))
// Room keys for a given room (client calls this on the econ host). [] with no DB.
Expand Down
Loading