优化:表结构

This commit is contained in:
桃树夭 committed 2026-08-04 14:47:12 +08:00
1 parent 252b4bd8aa
commit 6c88fd9f50
20 files changed
+6654 -119

No files matched your search

+7
View File
@@ -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
+7
View File
@@ -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',
+1 -65
View File
@@ -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',
{
+10
View File
@@ -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,
+2
View File
@@ -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,
};
}