优化:表结构
This commit is contained in:
1 parent
252b4bd8aa
commit
6c88fd9f50
20 files changed
+6654
-119
No files matched your search
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "wanjiedaoyou_local_transaction_messages" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE "wanjiedaoyou_sect_contribution_ledger" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE "wanjiedaoyou_sect_daily_commissions" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
DROP TABLE "wanjiedaoyou_local_transaction_messages" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "wanjiedaoyou_sect_contribution_ledger" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "wanjiedaoyou_sect_daily_commissions" CASCADE;--> statement-breakpoint
|
||||
CREATE INDEX "sect_stipend_claimed_idx" ON "wanjiedaoyou_sect_stipend_claims" USING btree ("claimed_at");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -176,6 +176,13 @@
|
||||
"when": 1785770540240,
|
||||
"tag": "0024_mute_molten_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "7",
|
||||
"when": 1785824804443,
|
||||
"tag": "0025_burly_paladin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useInkUI } from '@app/components/providers/InkUIProvider';
|
||||
import { InkButton } from '@app/components/ui';
|
||||
import type { SectTaskViewData } from '@shared/contracts/sect';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createSectRoomNpcHref } from './sectRoomNavigation';
|
||||
import {
|
||||
createSectTaskBattleHref,
|
||||
@@ -73,6 +74,95 @@ export function ClaimAction({
|
||||
);
|
||||
}
|
||||
|
||||
function readAbandonAvailability(action: SectTaskViewAction): {
|
||||
availableAt?: number;
|
||||
cooldownBlocked: boolean;
|
||||
} {
|
||||
const rawAvailableAt = action.parameters?.availableAt;
|
||||
const parsedAvailableAt =
|
||||
typeof rawAvailableAt === 'string' ? Date.parse(rawAvailableAt) : NaN;
|
||||
return {
|
||||
...(Number.isFinite(parsedAvailableAt)
|
||||
? { availableAt: parsedAvailableAt }
|
||||
: {}),
|
||||
cooldownBlocked: action.parameters?.cooldownBlocked === true,
|
||||
};
|
||||
}
|
||||
|
||||
function formatAbandonCountdown(remainingMs: number): string {
|
||||
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1_000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function AbandonAction({
|
||||
task,
|
||||
action,
|
||||
display,
|
||||
}: SectTaskActionRendererProps) {
|
||||
const { busy, execute } = useSectTaskInteraction();
|
||||
const { openDialog } = useInkUI();
|
||||
const availability = readAbandonAvailability(action);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const remainingMs = availability.availableAt
|
||||
? Math.max(0, availability.availableAt - now)
|
||||
: 0;
|
||||
const cooldownReady = availability.cooldownBlocked && remainingMs === 0;
|
||||
const enabled = action.enabled || cooldownReady;
|
||||
|
||||
useEffect(() => {
|
||||
if (!availability.cooldownBlocked || remainingMs === 0) return;
|
||||
const timer = window.setTimeout(
|
||||
() => setNow(Date.now()),
|
||||
Math.min(1_000, remainingMs),
|
||||
);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [availability.cooldownBlocked, remainingMs]);
|
||||
|
||||
const label =
|
||||
remainingMs > 0
|
||||
? `领取满 15 分钟后可放弃(${formatAbandonCountdown(remainingMs)})`
|
||||
: display === 'conversation'
|
||||
? '这份委托我不再办了'
|
||||
: action.label;
|
||||
|
||||
return (
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
className={actionClassName(display)}
|
||||
disabled={busy || !enabled}
|
||||
onClick={() =>
|
||||
openDialog({
|
||||
title: '放弃宗门任务',
|
||||
content: (
|
||||
<p className="text-ink-secondary text-sm leading-7">
|
||||
{`放弃「${task.presentation.title}」后,当前进度与已锁定目标都会作废;你可以立即重新领取,并生成一份新的任务内容。`}
|
||||
</p>
|
||||
),
|
||||
confirmLabel: '确认放弃',
|
||||
cancelLabel: '继续办理',
|
||||
loadingLabel: '正在撤下委托……',
|
||||
onConfirm: async () => {
|
||||
await execute(
|
||||
task,
|
||||
action,
|
||||
{},
|
||||
`已放弃「${task.presentation.title}」`,
|
||||
);
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{enabled
|
||||
? label
|
||||
: remainingMs > 0
|
||||
? label
|
||||
: (action.disabledReason ?? '暂不可放弃')}
|
||||
</InkButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function BattleAction({
|
||||
task,
|
||||
action,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AbandonAction,
|
||||
AcceptAction,
|
||||
BattleAction,
|
||||
ClaimAction,
|
||||
@@ -143,6 +144,7 @@ export const CORE_SECT_TASK_RENDERER_PLUGIN: SectTaskRendererPluginManifest = {
|
||||
sectId: '*',
|
||||
actions: [
|
||||
{ key: 'sect.action.accept', renderer: AcceptAction },
|
||||
{ key: 'sect.action.abandon', renderer: AbandonAction },
|
||||
{ key: 'sect.action.battle', renderer: BattleAction },
|
||||
{ key: 'sect.action.sweep-entry', renderer: SweepEntryAction },
|
||||
{ key: 'sect.action.mining-entry', renderer: MiningEntryAction },
|
||||
@@ -175,6 +177,11 @@ export const CORE_SECT_TASK_RENDERER_PLUGIN: SectTaskRendererPluginManifest = {
|
||||
schema: z.record(z.string(), z.unknown()),
|
||||
renderer: CompletedOutcome,
|
||||
},
|
||||
{
|
||||
key: 'sect.outcome.abandoned',
|
||||
schema: z.object({ abandoned: z.literal(true) }),
|
||||
renderer: CompletedOutcome,
|
||||
},
|
||||
{
|
||||
key: 'sect.outcome.fulfilled',
|
||||
schema: z.record(z.string(), z.unknown()),
|
||||
|
||||
@@ -477,6 +477,16 @@ function TaskConversation({
|
||||
body: '带来的东西已经验明,回执也已写好,现在可以交回结清。',
|
||||
tone: 'attention',
|
||||
});
|
||||
} else if (
|
||||
decoded?.ok &&
|
||||
decoded.value.renderer === 'sect.outcome.abandoned'
|
||||
) {
|
||||
messages.push({
|
||||
id: 'abandoned',
|
||||
speaker: npc.name,
|
||||
body: '这份委托已经从你名下撤下。若仍想经办,可以回到事务册重新领取。',
|
||||
tone: 'attention',
|
||||
});
|
||||
} else {
|
||||
messages.push({
|
||||
id: 'instruction',
|
||||
|
||||
@@ -273,29 +273,6 @@ export const sectFacilities = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @deprecated BullMQ 时代的本地执行消息表。仅为后续独立清理保留,禁止新增读写。
|
||||
*/
|
||||
export const legacyLocalTransactionMessages = pgTable(
|
||||
'wanjiedaoyou_local_transaction_messages',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
messageKey: varchar('message_key', { length: 96 }).notNull(),
|
||||
payload: jsonb('payload').notNull(),
|
||||
deduplicationKey: varchar('deduplication_key', { length: 256 }),
|
||||
completedAt: timestamp('completed_at'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('local_transaction_messages_dedupe_unique')
|
||||
.on(table.messageKey, table.deduplicationKey)
|
||||
.where(sql`${table.deduplicationKey} is not null`),
|
||||
index('local_transaction_messages_pending_idx')
|
||||
.on(table.createdAt)
|
||||
.where(sql`${table.completedAt} is null`),
|
||||
],
|
||||
);
|
||||
|
||||
export const transactionalMessages = pgTable(
|
||||
'wanjiedaoyou_transactional_messages',
|
||||
{
|
||||
@@ -368,27 +345,6 @@ export const sectTaskRecords = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const sectContributionLedger = pgTable(
|
||||
'wanjiedaoyou_sect_contribution_ledger',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
membershipId: uuid('membership_id')
|
||||
.references(() => sectMemberships.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
delta: integer('delta').notNull(),
|
||||
balanceAfter: integer('balance_after').notNull(),
|
||||
source: varchar('source', { length: 32 }).notNull(),
|
||||
referenceId: varchar('reference_id', { length: 128 }),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index('sect_contribution_membership_created_idx').on(
|
||||
table.membershipId,
|
||||
table.createdAt,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const sectStipendClaims = pgTable(
|
||||
'wanjiedaoyou_sect_stipend_claims',
|
||||
{
|
||||
@@ -405,6 +361,7 @@ export const sectStipendClaims = pgTable(
|
||||
table.membershipId,
|
||||
table.weekKey,
|
||||
),
|
||||
index('sect_stipend_claimed_idx').on(table.claimedAt),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -508,27 +465,6 @@ export const sectAbilityLoadouts = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const sectDailyCommissions = pgTable(
|
||||
'wanjiedaoyou_sect_daily_commissions',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
membershipId: uuid('membership_id')
|
||||
.references(() => sectMemberships.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
dateKey: varchar('date_key', { length: 10 }).notNull(),
|
||||
completionType: varchar('completion_type', { length: 16 }).notNull(),
|
||||
completedAt: timestamp('completed_at').notNull(),
|
||||
claimedAt: timestamp('claimed_at'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('sect_commission_membership_date_unique').on(
|
||||
table.membershipId,
|
||||
table.dateKey,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const resourceEvents = pgTable(
|
||||
'wanjiedaoyou_resource_events',
|
||||
{
|
||||
|
||||
@@ -42,7 +42,10 @@ const QI_LOG_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const DUNGEON_HISTORY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const DUNGEON_RUN_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const BATTLE_RECORD_V3_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const BET_BATTLE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const REPUTATION_SHOP_PURCHASE_RETENTION_MS = 21 * 24 * 60 * 60 * 1000;
|
||||
const SECT_SHOP_PURCHASE_RETENTION_MS = 8 * 7 * 24 * 60 * 60 * 1000;
|
||||
const SECT_STIPEND_CLAIM_RETENTION_MS = 60 * 24 * 60 * 60 * 1000;
|
||||
const AUCTION_LISTING_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const TRANSACTIONAL_MESSAGE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const MESSAGE_CONSUMPTION_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
@@ -392,9 +395,16 @@ export async function runExpiredDataCleanupJob(): Promise<
|
||||
dungeonHistories: new Date(now - DUNGEON_HISTORY_RETENTION_MS),
|
||||
dungeonRuns: new Date(now - DUNGEON_RUN_RETENTION_MS),
|
||||
battleRecordsV3: new Date(now - BATTLE_RECORD_V3_RETENTION_MS),
|
||||
betBattles: new Date(now - BET_BATTLE_RETENTION_MS),
|
||||
reputationShopPurchases: new Date(
|
||||
now - REPUTATION_SHOP_PURCHASE_RETENTION_MS,
|
||||
),
|
||||
sectShopPurchases: new Date(
|
||||
now - SECT_SHOP_PURCHASE_RETENTION_MS,
|
||||
),
|
||||
sectStipendClaims: new Date(
|
||||
now - SECT_STIPEND_CLAIM_RETENTION_MS,
|
||||
),
|
||||
auctionListings: new Date(now - AUCTION_LISTING_RETENTION_MS),
|
||||
},
|
||||
tx,
|
||||
|
||||
@@ -3,11 +3,14 @@ import { getExecutor } from '@server/lib/drizzle/db';
|
||||
import {
|
||||
auctionListings,
|
||||
battleRecordsV3,
|
||||
betBattles,
|
||||
dungeonHistories,
|
||||
dungeonRuns,
|
||||
mails,
|
||||
qiLogs,
|
||||
reputationShopPurchases,
|
||||
sectShopPurchases,
|
||||
sectStipendClaims,
|
||||
} from '@server/lib/drizzle/schema';
|
||||
import { and, inArray, lt, ne, sql } from 'drizzle-orm';
|
||||
|
||||
@@ -17,7 +20,10 @@ export type ExpiredDataCleanupCutoffs = {
|
||||
dungeonHistories: Date;
|
||||
dungeonRuns: Date;
|
||||
battleRecordsV3: Date;
|
||||
betBattles: Date;
|
||||
reputationShopPurchases: Date;
|
||||
sectShopPurchases: Date;
|
||||
sectStipendClaims: Date;
|
||||
auctionListings: Date;
|
||||
};
|
||||
|
||||
@@ -27,7 +33,10 @@ export type ExpiredDataCleanupResult = {
|
||||
dungeonHistories: number;
|
||||
dungeonRuns: number;
|
||||
battleRecordsV3: number;
|
||||
betBattles: number;
|
||||
reputationShopPurchases: number;
|
||||
sectShopPurchases: number;
|
||||
sectStipendClaims: number;
|
||||
auctionListings: number;
|
||||
};
|
||||
|
||||
@@ -85,6 +94,18 @@ export async function pruneExpiredData(
|
||||
.returning({ id: battleRecordsV3.id }),
|
||||
);
|
||||
|
||||
const betBattlesDeleted = await deleteExpiredRows(q, (executor) =>
|
||||
executor
|
||||
.delete(betBattles)
|
||||
.where(
|
||||
and(
|
||||
inArray(betBattles.status, ['settled', 'cancelled', 'expired']),
|
||||
lt(betBattles.createdAt, cutoffs.betBattles),
|
||||
),
|
||||
)
|
||||
.returning({ id: betBattles.id }),
|
||||
);
|
||||
|
||||
const reputationShopPurchasesDeleted = await deleteExpiredRows(
|
||||
q,
|
||||
(executor) =>
|
||||
@@ -99,6 +120,20 @@ export async function pruneExpiredData(
|
||||
.returning({ id: reputationShopPurchases.id }),
|
||||
);
|
||||
|
||||
const sectShopPurchasesDeleted = await deleteExpiredRows(q, (executor) =>
|
||||
executor
|
||||
.delete(sectShopPurchases)
|
||||
.where(lt(sectShopPurchases.createdAt, cutoffs.sectShopPurchases))
|
||||
.returning({ id: sectShopPurchases.id }),
|
||||
);
|
||||
|
||||
const sectStipendClaimsDeleted = await deleteExpiredRows(q, (executor) =>
|
||||
executor
|
||||
.delete(sectStipendClaims)
|
||||
.where(lt(sectStipendClaims.claimedAt, cutoffs.sectStipendClaims))
|
||||
.returning({ id: sectStipendClaims.id }),
|
||||
);
|
||||
|
||||
const auctionListingsDeleted = await deleteExpiredRows(q, (executor) =>
|
||||
executor
|
||||
.delete(auctionListings)
|
||||
@@ -118,7 +153,10 @@ export async function pruneExpiredData(
|
||||
dungeonHistories: dungeonHistoriesDeleted,
|
||||
dungeonRuns: dungeonRunsDeleted,
|
||||
battleRecordsV3: battleRecordsV3Deleted,
|
||||
betBattles: betBattlesDeleted,
|
||||
reputationShopPurchases: reputationShopPurchasesDeleted,
|
||||
sectShopPurchases: sectShopPurchasesDeleted,
|
||||
sectStipendClaims: sectStipendClaimsDeleted,
|
||||
auctionListings: auctionListingsDeleted,
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
creationProducts,
|
||||
cultivators,
|
||||
materials,
|
||||
sectContributionLedger,
|
||||
sectFacilities,
|
||||
sectMemberships,
|
||||
sectStipendClaims,
|
||||
@@ -16,7 +15,7 @@ import {
|
||||
} from '@server/lib/drizzle/schema';
|
||||
import type { SectDiscipleRank, SectOffice } from '@shared/engine/sect';
|
||||
import type { RealmType } from '@shared/types/constants';
|
||||
import { and, asc, count, desc, eq, gt, gte, inArray, sql } from 'drizzle-orm';
|
||||
import { and, asc, count, desc, eq, gt, gte, inArray, lte, sql } from 'drizzle-orm';
|
||||
|
||||
export async function ensureSectFacilities(
|
||||
sectId: string,
|
||||
@@ -91,8 +90,6 @@ export async function saveSectFacilityConstruction(
|
||||
export async function addSectContribution(
|
||||
membershipId: string,
|
||||
amount: number,
|
||||
source: string,
|
||||
referenceId: string | null,
|
||||
tx: DbTransaction,
|
||||
) {
|
||||
const [membership] = await tx
|
||||
@@ -104,21 +101,12 @@ export async function addSectContribution(
|
||||
.where(eq(sectMemberships.id, membershipId))
|
||||
.returning({ contribution: sectMemberships.contribution });
|
||||
if (!membership) throw new Error('宗门成员不存在');
|
||||
await tx.insert(sectContributionLedger).values({
|
||||
membershipId,
|
||||
delta: amount,
|
||||
balanceAfter: membership.contribution,
|
||||
source,
|
||||
referenceId,
|
||||
});
|
||||
return membership.contribution;
|
||||
}
|
||||
|
||||
export async function spendSectContribution(
|
||||
membershipId: string,
|
||||
amount: number,
|
||||
source: string,
|
||||
referenceId: string | null,
|
||||
tx: DbTransaction,
|
||||
) {
|
||||
const [membership] = await tx
|
||||
@@ -135,13 +123,6 @@ export async function spendSectContribution(
|
||||
)
|
||||
.returning({ contribution: sectMemberships.contribution });
|
||||
if (!membership) return null;
|
||||
await tx.insert(sectContributionLedger).values({
|
||||
membershipId,
|
||||
delta: -amount,
|
||||
balanceAfter: membership.contribution,
|
||||
source,
|
||||
referenceId,
|
||||
});
|
||||
return membership.contribution;
|
||||
}
|
||||
|
||||
@@ -242,6 +223,24 @@ export async function completeSectTaskRecord(
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function abandonSectTaskRecord(
|
||||
id: string,
|
||||
acceptedBefore: Date,
|
||||
tx: DbTransaction,
|
||||
): Promise<boolean> {
|
||||
const [row] = await tx
|
||||
.delete(sectTaskRecords)
|
||||
.where(
|
||||
and(
|
||||
eq(sectTaskRecords.id, id),
|
||||
eq(sectTaskRecords.status, 'active'),
|
||||
lte(sectTaskRecords.createdAt, acceptedBefore),
|
||||
),
|
||||
)
|
||||
.returning({ id: sectTaskRecords.id });
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
export async function updateSectTaskPayload(
|
||||
id: string,
|
||||
payload: Record<string, unknown>,
|
||||
|
||||
@@ -82,6 +82,7 @@ export class ClaimSectTaskRewardHandler {
|
||||
definition: args.definition,
|
||||
record: claimed,
|
||||
executor: args.executor,
|
||||
now: args.context.clock.now(),
|
||||
state: 'claimed',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ export class GetSectTasksQueryHandler {
|
||||
);
|
||||
const dateKey = context.clock.dateKey();
|
||||
const weekKey = context.clock.weekKey();
|
||||
const now = context.clock.now();
|
||||
const records = await context.tasks.list(membership.id, [
|
||||
dateKey,
|
||||
weekKey,
|
||||
@@ -104,6 +105,7 @@ export class GetSectTasksQueryHandler {
|
||||
definition,
|
||||
record: persisted,
|
||||
executor,
|
||||
now,
|
||||
state,
|
||||
enabled,
|
||||
disabledReason: enabled
|
||||
|
||||
@@ -84,6 +84,7 @@ function mapTask(row: {
|
||||
status: string;
|
||||
progress: number;
|
||||
payload: unknown;
|
||||
createdAt: Date;
|
||||
completedAt: Date | null;
|
||||
claimedAt: Date | null;
|
||||
}): SectTaskRecord {
|
||||
@@ -96,6 +97,7 @@ function mapTask(row: {
|
||||
status: row.status as SectTaskRecord['status'],
|
||||
progress: row.progress,
|
||||
payload: SectTaskRecordPayloadSchema.parse(row.payload),
|
||||
createdAt: row.createdAt,
|
||||
completedAt: row.completedAt ?? undefined,
|
||||
claimedAt: row.claimedAt ?? undefined,
|
||||
};
|
||||
@@ -618,18 +620,11 @@ async function buildSubmissionInventoryChange(
|
||||
|
||||
function rewardAdapter(q: DbExecutor | DbTransaction, userId: string) {
|
||||
return {
|
||||
async grantContribution(
|
||||
membershipId: string,
|
||||
amount: number,
|
||||
reason: string,
|
||||
referenceId: string,
|
||||
) {
|
||||
async grantContribution(membershipId: string, amount: number) {
|
||||
if (!('rollback' in q)) throw new Error('宗门奖励必须在事务中执行');
|
||||
const balance = await organization.addSectContribution(
|
||||
membershipId,
|
||||
amount,
|
||||
reason,
|
||||
referenceId,
|
||||
q,
|
||||
);
|
||||
const effects = emptySectCommandEffects();
|
||||
@@ -723,17 +718,10 @@ function economyReadAdapter(
|
||||
function economyCommandAdapter(tx: DbTransaction): SectEconomyRepository {
|
||||
return {
|
||||
...economyReadAdapter(tx),
|
||||
async spendContribution(
|
||||
membershipId: string,
|
||||
amount: number,
|
||||
reason: string,
|
||||
referenceId: string,
|
||||
) {
|
||||
async spendContribution(membershipId: string, amount: number) {
|
||||
return organization.spendSectContribution(
|
||||
membershipId,
|
||||
amount,
|
||||
reason,
|
||||
referenceId,
|
||||
tx,
|
||||
);
|
||||
},
|
||||
@@ -754,17 +742,10 @@ function constructionCommandAdapter(
|
||||
tx: DbTransaction,
|
||||
): SectConstructionRepository {
|
||||
return {
|
||||
async grantContribution(
|
||||
membershipId: string,
|
||||
amount: number,
|
||||
reason: string,
|
||||
referenceId: string,
|
||||
) {
|
||||
async grantContribution(membershipId: string, amount: number) {
|
||||
return organization.addSectContribution(
|
||||
membershipId,
|
||||
amount,
|
||||
reason,
|
||||
referenceId,
|
||||
tx,
|
||||
);
|
||||
},
|
||||
@@ -930,6 +911,8 @@ export function createPostgresSectCommandContext(args: {
|
||||
const row = await organization.completeSectTaskRecord(id, progress, tx);
|
||||
return row ? mapTask(row) : null;
|
||||
},
|
||||
abandon: (id, acceptedBefore) =>
|
||||
organization.abandonSectTaskRecord(id, acceptedBefore, tx),
|
||||
updatePayload: async (id, payload) => {
|
||||
const row = await organization.updateSectTaskPayload(
|
||||
id,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ResourceChangeDescriptor } from '@shared/contracts/resources';
|
||||
import type { SectTaskActionData } from '@shared/contracts/sect';
|
||||
import {
|
||||
SECT_TASK_ABANDON_COOLDOWN_MS,
|
||||
SectTask,
|
||||
SectTaskRecordPayloadSchema,
|
||||
resolveSectTaskAbandonAvailability,
|
||||
type SectTaskDefinition,
|
||||
} from '@shared/engine/sect';
|
||||
import { z } from 'zod';
|
||||
@@ -20,7 +22,10 @@ import type {
|
||||
SectTaskOfferPolicyRegistry,
|
||||
SectTaskRewardPolicyRegistry,
|
||||
} from './SectTaskSettlement';
|
||||
import { toSectTaskView } from './SectTaskViewAssembler';
|
||||
import {
|
||||
toSectTaskView,
|
||||
toUnpersistedSectTaskView,
|
||||
} from './SectTaskViewAssembler';
|
||||
import type {
|
||||
SectCommandContext,
|
||||
SectMembershipRecord,
|
||||
@@ -144,6 +149,7 @@ export class ExecuteSectTaskActionHandler {
|
||||
definition,
|
||||
record,
|
||||
executor,
|
||||
now: context.clock.now(),
|
||||
state: record.claimedAt
|
||||
? 'claimed'
|
||||
: record.status === 'completed'
|
||||
@@ -193,6 +199,7 @@ export class ExecuteSectTaskActionHandler {
|
||||
definition,
|
||||
record,
|
||||
executor,
|
||||
now: context.clock.now(),
|
||||
state: 'active',
|
||||
enabled: true,
|
||||
});
|
||||
@@ -268,6 +275,49 @@ export class ExecuteSectTaskActionHandler {
|
||||
});
|
||||
return this.complete(claimed.result, claimed.effects);
|
||||
}
|
||||
if (command.actionKey === 'abandon') {
|
||||
if (definition.enrollment !== 'manual')
|
||||
invalidSectTask('该任务不支持放弃', 400);
|
||||
if (record.status !== 'active')
|
||||
invalidSectTask(
|
||||
record.claimedAt ? '该宗门任务已经结清' : '已完成任务无法放弃',
|
||||
);
|
||||
if (!acceptInput.safeParse(command.input).success)
|
||||
invalidSectTask('放弃参数无效', 400);
|
||||
const now = context.clock.now();
|
||||
const availability = resolveSectTaskAbandonAvailability(
|
||||
record.createdAt,
|
||||
now,
|
||||
);
|
||||
if (!availability.allowed) {
|
||||
const remainingMinutes = Math.max(
|
||||
1,
|
||||
Math.ceil(availability.remainingMs / 60_000),
|
||||
);
|
||||
invalidSectTask(
|
||||
`领取满 15 分钟后方可放弃,还需等待约 ${remainingMinutes} 分钟`,
|
||||
);
|
||||
}
|
||||
const acceptedBefore = new Date(
|
||||
now.getTime() - SECT_TASK_ABANDON_COOLDOWN_MS,
|
||||
);
|
||||
if (!(await context.tasks.abandon(record.id, acceptedBefore)))
|
||||
invalidSectTask('任务状态已经变化,请重试');
|
||||
const primaryTask = toUnpersistedSectTaskView({
|
||||
definition,
|
||||
periodKey,
|
||||
state: 'offered',
|
||||
enabled: true,
|
||||
});
|
||||
return this.complete({
|
||||
primaryTask,
|
||||
changedTasks: [primaryTask],
|
||||
outcome: {
|
||||
renderer: 'sect.outcome.abandoned',
|
||||
data: { abandoned: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (record.status === 'completed')
|
||||
invalidSectTask(
|
||||
record.claimedAt ? '该宗门任务已经结清' : '该宗门任务奖励待领取',
|
||||
@@ -320,6 +370,7 @@ export class ExecuteSectTaskActionHandler {
|
||||
const linkedTasks = this.toChangedTaskViews(
|
||||
linkedTaskRecords,
|
||||
organization,
|
||||
context.clock.now(),
|
||||
);
|
||||
if (decision.completionSettlement === 'claim-reward') {
|
||||
const claimed = await this.claims.execute({
|
||||
@@ -341,6 +392,7 @@ export class ExecuteSectTaskActionHandler {
|
||||
definition,
|
||||
record,
|
||||
executor,
|
||||
now: context.clock.now(),
|
||||
state: record.status === 'completed' ? 'claimable' : 'active',
|
||||
enabled: true,
|
||||
});
|
||||
@@ -349,7 +401,11 @@ export class ExecuteSectTaskActionHandler {
|
||||
primaryTask,
|
||||
changedTasks: mergeChangedTasks(
|
||||
primaryTask,
|
||||
this.toChangedTaskViews(linkedTaskRecords, organization),
|
||||
this.toChangedTaskViews(
|
||||
linkedTaskRecords,
|
||||
organization,
|
||||
context.clock.now(),
|
||||
),
|
||||
),
|
||||
outcome: decision.outcome,
|
||||
},
|
||||
@@ -388,6 +444,7 @@ export class ExecuteSectTaskActionHandler {
|
||||
private toChangedTaskViews(
|
||||
records: readonly SectTaskRecord[],
|
||||
organization: ReturnType<SectCommandContext['modules']['require']>,
|
||||
now: Date,
|
||||
) {
|
||||
return records.map((record) => {
|
||||
const definition = organization.tasks.get(record.taskId);
|
||||
@@ -397,6 +454,7 @@ export class ExecuteSectTaskActionHandler {
|
||||
definition,
|
||||
record,
|
||||
executor: this.executors.require(record.payload.offer.executorKey),
|
||||
now,
|
||||
state: record.claimedAt
|
||||
? 'claimed'
|
||||
: record.status === 'completed'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SectTaskViewData } from '@shared/contracts/sect';
|
||||
import {
|
||||
resolveSectTaskClaimReward,
|
||||
resolveSectTaskAbandonAvailability,
|
||||
resolveSectTaskDialogue,
|
||||
resolveSectTaskExecutionLocationParameters,
|
||||
readSectBattleTargetSnapshot,
|
||||
@@ -73,6 +74,7 @@ export function toSectTaskView(args: {
|
||||
record: SectTaskRecord;
|
||||
state: SectTaskViewData['state'];
|
||||
executor: SectTaskExecutor;
|
||||
now: Date;
|
||||
enabled: boolean;
|
||||
disabledReason?: string;
|
||||
}): SectTaskViewData {
|
||||
@@ -84,6 +86,10 @@ export function toSectTaskView(args: {
|
||||
const executionLocation = resolveSectTaskExecutionLocationParameters(
|
||||
args.definition,
|
||||
);
|
||||
const abandonAvailability = resolveSectTaskAbandonAvailability(
|
||||
args.record.createdAt,
|
||||
args.now,
|
||||
);
|
||||
const actions =
|
||||
args.state === 'claimed'
|
||||
? []
|
||||
@@ -113,13 +119,35 @@ export function toSectTaskView(args: {
|
||||
: {}),
|
||||
},
|
||||
]
|
||||
: args.executor.actions(args.definition).map((action) => ({
|
||||
...action,
|
||||
enabled: args.enabled,
|
||||
...(args.disabledReason
|
||||
? { disabledReason: args.disabledReason }
|
||||
: {}),
|
||||
}));
|
||||
: [
|
||||
...args.executor.actions(args.definition).map((action) => ({
|
||||
...action,
|
||||
enabled: args.enabled,
|
||||
...(args.disabledReason
|
||||
? { disabledReason: args.disabledReason }
|
||||
: {}),
|
||||
})),
|
||||
...(args.definition.enrollment === 'manual'
|
||||
? [
|
||||
{
|
||||
key: 'abandon',
|
||||
renderer: 'sect.action.abandon',
|
||||
label: '放弃此事',
|
||||
enabled: args.enabled && abandonAvailability.allowed,
|
||||
parameters: {
|
||||
availableAt:
|
||||
abandonAvailability.availableAt.toISOString(),
|
||||
cooldownBlocked: !abandonAvailability.allowed,
|
||||
},
|
||||
...(!args.enabled && args.disabledReason
|
||||
? { disabledReason: args.disabledReason }
|
||||
: !abandonAvailability.allowed
|
||||
? { disabledReason: '领取满 15 分钟后方可放弃' }
|
||||
: {}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return {
|
||||
id: args.record.id,
|
||||
definitionId: args.definition.id,
|
||||
|
||||
@@ -153,6 +153,7 @@ export interface SectTaskRecord {
|
||||
status: 'active' | 'completed';
|
||||
progress: number;
|
||||
payload: SectTaskRecordPayload;
|
||||
createdAt: Date;
|
||||
completedAt?: Date;
|
||||
claimedAt?: Date;
|
||||
}
|
||||
@@ -216,6 +217,7 @@ export interface SectTaskRepository extends SectTaskReadRepository {
|
||||
payload: SectTaskRecordPayload;
|
||||
}): Promise<SectTaskRecord>;
|
||||
complete(id: string, progress: number): Promise<SectTaskRecord | null>;
|
||||
abandon(id: string, acceptedBefore: Date): Promise<boolean>;
|
||||
claim(id: string, claimedAt: Date): Promise<SectTaskRecord | null>;
|
||||
updatePayload(
|
||||
id: string,
|
||||
|
||||
@@ -133,6 +133,8 @@ t = clamp(floor(level), 0, 180) / 180
|
||||
|
||||
手动任务只在领取事务中生成。领取前的任务视图只包含任务类型、名称和通用描述,不生成或返回执行变体、交付要求、难度和奖励;领取时根据当前周期、身份与境界生成完整 offer,并将执行方式、要求、难度和奖励作为 v2 快照原子写入任务记录。领取后不能刷新或更换快照。
|
||||
|
||||
手动任务领取满 15 分钟后可以回到发放事务的 NPC 处放弃。放弃只允许作用于尚未完成的 active 任务,会删除当前任务记录、进度、执行会话和目标快照;随后可以在同一周期重新领取,并生成新的任务快照。已完成待领奖或已结清的任务不能放弃。
|
||||
|
||||
任务修为继续使用境界每日预算。日常周期倍率为 1,周常周期倍率为 3;easy、normal、hard、elite 分别取得每日预算的 5%、8%、12%、18%。灵石与贡献公式为:
|
||||
|
||||
```text
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from './specifications';
|
||||
export * from './StandardSectCapabilityPolicy';
|
||||
export * from './StandardSectOrganizationModule';
|
||||
export * from './taskDialogue';
|
||||
export * from './taskAbandon';
|
||||
export * from './taskBattleTarget';
|
||||
export * from './taskOffer';
|
||||
export * from './taskRequirementMatcher';
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
SECT_TASK_ABANDON_COOLDOWN_MS,
|
||||
resolveSectTaskAbandonAvailability,
|
||||
} from './taskAbandon';
|
||||
|
||||
describe('sect task abandon availability', () => {
|
||||
const acceptedAt = new Date('2026-08-04T04:00:00.000Z');
|
||||
|
||||
it('blocks abandonment before the fifteen-minute cooldown ends', () => {
|
||||
const result = resolveSectTaskAbandonAvailability(
|
||||
acceptedAt,
|
||||
new Date(acceptedAt.getTime() + SECT_TASK_ABANDON_COOLDOWN_MS - 1),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: false,
|
||||
availableAt: new Date('2026-08-04T04:15:00.000Z'),
|
||||
remainingMs: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows abandonment exactly when the cooldown ends', () => {
|
||||
const result = resolveSectTaskAbandonAvailability(
|
||||
acceptedAt,
|
||||
new Date(acceptedAt.getTime() + SECT_TASK_ABANDON_COOLDOWN_MS),
|
||||
);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remainingMs).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps abandonment available after the cooldown has ended', () => {
|
||||
const result = resolveSectTaskAbandonAvailability(
|
||||
acceptedAt,
|
||||
new Date(acceptedAt.getTime() + SECT_TASK_ABANDON_COOLDOWN_MS + 60_000),
|
||||
);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remainingMs).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
export const SECT_TASK_ABANDON_COOLDOWN_MS = 15 * 60 * 1_000;
|
||||
|
||||
export interface SectTaskAbandonAvailability {
|
||||
allowed: boolean;
|
||||
availableAt: Date;
|
||||
remainingMs: number;
|
||||
}
|
||||
|
||||
export function resolveSectTaskAbandonAvailability(
|
||||
acceptedAt: Date,
|
||||
now: Date,
|
||||
): SectTaskAbandonAvailability {
|
||||
const availableAt = new Date(
|
||||
acceptedAt.getTime() + SECT_TASK_ABANDON_COOLDOWN_MS,
|
||||
);
|
||||
const remainingMs = Math.max(0, availableAt.getTime() - now.getTime());
|
||||
return {
|
||||
allowed: remainingMs === 0,
|
||||
availableAt,
|
||||
remainingMs,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user