Merge pull request #60 from ChurchTao/codex/tjc/bugfix

Optimize battle page return button and fix 'Tai Xu' entry bug
This commit is contained in:
桃树夭 authored and GitHub committed 2026-08-21 18:38:18 +08:00
commit f6673bee9c
25 files changed
+7863 -16

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
ALTER TABLE "wanjiedaoyou_sect_memberships" ADD COLUMN "lifetime_contribution" integer DEFAULT 0 NOT NULL;
UPDATE "wanjiedaoyou_sect_memberships"
SET "lifetime_contribution" = "contribution"
WHERE "lifetime_contribution" = 0 AND "contribution" > 0;
File diff suppressed because it is too large. Load diff
+8 -1
View File
@@ -225,6 +225,13 @@
"when": 1786931826016,
"tag": "0031_damp_karnak",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1787307226056,
"tag": "0032_rainy_ender_wiggin",
"breakpoints": true
}
]
}
}
@@ -1,8 +1,10 @@
import { InkButton } from '@app/components/ui/InkButton';
import type { UnitStateSnapshot } from '@shared/engine/battle-v5/systems/state/types';
import type {
BattlePlaybackRecordV3,
PublicBattleUnitSnapshotV1,
} from '@shared/types/battle';
import { useNavigate } from 'react-router';
import { CombatAttributeModal } from '../v5/CombatAttributeModal';
import { CombatControlBar } from '../v5/CombatControlBar';
import { CombatStatusHeader } from '../v5/CombatStatusHeader';
@@ -32,10 +34,25 @@ export function BattlePlaybackPanel({
playback,
statusActions,
}: BattlePlaybackPanelProps) {
const navigate = useNavigate();
if (!battleResult) {
return null;
}
const hasExitAction = statusActions?.some((action) =>
/返回|离开|进入游戏/.test(action.label),
);
const resolvedStatusActions: BattleStatusAction[] = hasExitAction
? (statusActions ?? [])
: [
{ label: '返回', onClick: () => navigate(-1) },
...(statusActions ?? []),
];
const hasBattleFrames = Boolean(
playback.currentPlayerFrame && playback.currentOpponentFrame,
);
return (
<>
<div className="flex h-full min-h-0 flex-col gap-3 md:gap-4">
@@ -71,7 +88,7 @@ export function BattlePlaybackPanel({
onReset={playback.reset}
/>
}
statusActions={statusActions}
statusActions={resolvedStatusActions}
/>
)}
@@ -81,6 +98,24 @@ export function BattlePlaybackPanel({
/>
</div>
{!hasBattleFrames ? (
<div className="battle-dock fixed inset-x-0 bottom-0 z-40 select-none">
<div className="mx-auto flex max-w-4xl items-center gap-3 pt-2 pr-[max(env(safe-area-inset-right),0.75rem)] pb-[calc(env(safe-area-inset-bottom)+0.9rem)] pl-[max(env(safe-area-inset-left),0.75rem)] md:pr-[max(env(safe-area-inset-right),1.5rem)] md:pl-[max(env(safe-area-inset-left),1.5rem)]">
{resolvedStatusActions.map((action) => (
<InkButton
key={action.label}
variant="ghost"
onClick={action.onClick}
href={action.href}
className="px-0 py-0 text-[13px] leading-5"
>
{action.label}
</InkButton>
))}
</div>
</div>
) : null}
<CombatAttributeModal
unit={
hasExactAttributes(playback.selectedUnit)
@@ -105,6 +105,14 @@ export function SectIdentityDetails({
</span>
}
/>
<IdentityRow
label="累计贡献"
value={
<span className="text-wood/80 font-mono">
{formatContribution(context.lifetimeContribution)}
</span>
}
/>
{showJoinedAt ? (
<IdentityRow
label="入门时间"
@@ -190,6 +190,7 @@ function membershipState(
joinedAt: context.joinedAt,
discipleRank: context.discipleRank,
contribution: context.contribution,
lifetimeContribution: context.lifetimeContribution,
office: context.office,
promotedAt: context.promotedAt,
configVersion: context.configVersion,
@@ -86,7 +86,6 @@ export default function BattleReplayPage() {
playback={playback}
statusActions={[
{ label: '分享', onClick: () => setShareOpen(true) },
{ label: '返回战绩', href: '/game/battle/history' },
]}
/>
{battleResult ? (
+1
View File
@@ -230,6 +230,7 @@ export const sectMemberships = pgTable(
joinedAt: timestamp('joined_at'),
activePathId: varchar('active_path_id', { length: 64 }),
contribution: integer('contribution').notNull().default(0),
lifetimeContribution: integer('lifetime_contribution').notNull().default(0),
discipleRank: varchar('disciple_rank', { length: 16 })
.notNull()
.default('registered'),
@@ -96,6 +96,8 @@ export async function addSectContribution(
.update(sectMemberships)
.set({
contribution: sql`${sectMemberships.contribution} + ${amount}`,
lifetimeContribution:
sql`${sectMemberships.lifetimeContribution} + ${amount}`,
updatedAt: new Date(),
})
.where(eq(sectMemberships.id, membershipId))
@@ -197,6 +197,7 @@ async function hydrateMembership(
joinedAt: membership.joinedAt?.toISOString(),
activePathId: membership.activePathId ?? undefined,
contribution: membership.contribution,
lifetimeContribution: membership.lifetimeContribution,
discipleRank:
membership.discipleRank as CultivatorSectState['discipleRank'],
office: membership.office as CultivatorSectState['office'],
@@ -321,6 +322,8 @@ export async function activateMembership(
status: 'active',
joinedAt: new Date(),
contribution: definition.onboarding.initialContribution,
// Rejoining a sect resets spendable balance, but never erases earned history.
lifetimeContribution: sql`GREATEST(${sectMemberships.lifetimeContribution}, ${definition.onboarding.initialContribution})`,
activePathId: null,
configVersion: definition.configVersion,
})
@@ -270,6 +270,7 @@ function membershipQueryAdapter(
cultivatorId: row.cultivatorId,
discipleRank: row.discipleRank as SectDiscipleRank,
contribution: row.contribution,
lifetimeContribution: row.lifetimeContribution,
}
: null;
},
@@ -877,6 +878,7 @@ export function createPostgresSectCommandContext(args: {
cultivatorId: row.cultivatorId,
discipleRank: row.discipleRank as SectDiscipleRank,
contribution: row.contribution,
lifetimeContribution: row.lifetimeContribution,
}
: null;
},
@@ -1051,6 +1053,7 @@ export function createPostgresSectQueryContext(args: {
cultivatorId: row.cultivatorId,
discipleRank: row.discipleRank as SectDiscipleRank,
contribution: row.contribution,
lifetimeContribution: row.lifetimeContribution,
}
: null;
},
@@ -78,6 +78,7 @@ export class SectAdmissionApplicationService {
joinedAt: sect.joinedAt,
discipleRank,
contribution: sect.contribution,
lifetimeContribution: sect.lifetimeContribution ?? sect.contribution,
office: sect.office ?? 'none',
promotedAt: sect.promotedAt,
permissions: organization.capabilities.snapshot(discipleRank),
@@ -97,6 +97,7 @@ export class SectMembershipApplicationService {
realm,
stage,
contribution: membership.contribution,
lifetimeContribution: membership.lifetimeContribution,
dailyCompletions,
completedTaskTags,
},
@@ -171,6 +172,8 @@ export class SectMembershipApplicationService {
nextRank,
missing,
allowed: Boolean(nextRank && missing.length === 0),
contribution: membership.contribution,
lifetimeContribution: membership.lifetimeContribution,
};
}
@@ -222,6 +225,8 @@ export class SectMembershipApplicationService {
joinedAt: result.joinedAt,
discipleRank,
contribution: result.contribution,
lifetimeContribution:
result.lifetimeContribution ?? result.contribution,
office: result.office ?? 'none',
promotedAt: result.promotedAt,
permissions: organization.capabilities.snapshot(discipleRank),
@@ -142,6 +142,7 @@ export interface SectMembershipRecord {
cultivatorId: string;
discipleRank: SectDiscipleRank;
contribution: number;
lifetimeContribution: number;
}
export interface SectTaskRecord {
+1
View File
@@ -175,6 +175,7 @@ export function createSectsRouter(
discipleRank:
membership.discipleRank as SectContextData['discipleRank'],
contribution: membership.contribution,
lifetimeContribution: membership.lifetimeContribution,
office: membership.office as SectContextData['office'],
promotedAt: membership.promotedAt?.toISOString(),
permissions: organization.capabilities.snapshot(
+1
View File
@@ -148,6 +148,7 @@ export const SECT_RESOURCE_DATA_SCHEMAS = {
joinedAt: z.string().optional(),
discipleRank: z.enum(['registered', 'outer', 'inner', 'true']),
contribution: z.number(),
lifetimeContribution: z.number(),
office: z.enum(['none', 'steward', 'protector', 'elder']),
promotedAt: z.string().optional(),
permissions: z.record(
+5
View File
@@ -272,6 +272,7 @@ export interface SectContextData {
joinedAt?: string;
discipleRank: SectDiscipleRank;
contribution: number;
lifetimeContribution: number;
office: CultivatorSectState['office'];
promotedAt?: string;
permissions: Record<string, SectPermissionState>;
@@ -303,6 +304,8 @@ export interface SectPromotionEvaluationData {
nextRank: SectDiscipleRank | null;
missing: string[];
allowed: boolean;
contribution: number;
lifetimeContribution: number;
}
export const SectPromotionEvaluationDataSchema: z.ZodType<SectPromotionEvaluationData> =
@@ -311,6 +314,8 @@ export const SectPromotionEvaluationDataSchema: z.ZodType<SectPromotionEvaluatio
nextRank: z.enum(['registered', 'outer', 'inner', 'true']).nullable(),
missing: z.array(z.string()),
allowed: z.boolean(),
contribution: z.number().int().nonnegative(),
lifetimeContribution: z.number().int().nonnegative(),
})
.strict();
@@ -563,6 +563,8 @@ export interface PercentDamageModifierParams {
mode: 'increase' | 'reduce';
value: number;
cap?: number;
/** 条件满足并实际应用修正时,在战斗日志中显示该触发名称。 */
logTriggerName?: string;
/** 按产生监听器的 Buff 当前层数放大数值。 */
scaleByBuffLayer?: boolean;
allowedDamageSources?: DamageSource[];
@@ -41,11 +41,21 @@ export class PercentDamageModifierEffect extends GameplayEffect {
if (this.params.mode === 'increase') {
damageRequestEvent.damageIncreasePctBucket =
(damageRequestEvent.damageIncreasePctBucket ?? 0) + value;
return;
} else if (this.params.mode === 'reduce') {
damageRequestEvent.damageReductionPctBucket =
(damageRequestEvent.damageReductionPctBucket ?? 0) + value;
}
if (this.params.logTriggerName && value > 0) {
context.commit(damageRequestEvent.target, {
type: 'mechanic',
code: 'conditional_damage_modifier_trigger',
payload: {
kind: 'named_trigger',
label: this.params.logTriggerName,
},
});
}
}
}
@@ -6,6 +6,7 @@ import { AbilityType, AttributeType, BuffType, DamageType } from '../../core/typ
import { AbilityFactory } from '../../factories/AbilityFactory';
import { BuffFactory } from '../../factories/BuffFactory';
import { Unit } from '../../units/Unit';
import type { CombatResultCommittedEventV3 } from '../../v3/events';
describe('免疫效果集成测试', () => {
beforeEach(() => {
@@ -22,7 +23,11 @@ describe('免疫效果集成测试', () => {
});
}
function addPassiveDamageListener(unit: Unit, effects: Array<EffectConfig>): void {
function addPassiveDamageListener(
unit: Unit,
effects: Array<EffectConfig>,
eventType: 'DamageEvent' | 'DamageRequestEvent' = 'DamageEvent',
): void {
unit.abilities.addAbility(
AbilityFactory.create({
slug: `passive_${effects.map((effect) => effect.type).join('_')}`,
@@ -31,7 +36,7 @@ describe('免疫效果集成测试', () => {
tags: [GameplayTags.ABILITY.KIND.PASSIVE],
listeners: [
{
eventType: 'DamageEvent',
eventType,
scope: 'owner_as_target',
priority: EventPriorityLevel.DAMAGE_APPLY,
effects,
@@ -41,6 +46,54 @@ describe('免疫效果集成测试', () => {
);
}
it('条件减伤实际生效时应提交带词条名称的战斗日志事实', () => {
const attacker = createUnit('attacker', '进攻者');
const defender = createUnit('defender', '防御者');
addPassiveDamageListener(
defender,
[
{
type: 'percent_damage_modifier',
params: { mode: 'reduce', value: 0.5, logTriggerName: '金甲' },
},
],
'DamageRequestEvent',
);
const committed: CombatResultCommittedEventV3[] = [];
const handler = (event: CombatResultCommittedEventV3) => {
committed.push(event);
};
EventBus.instance.subscribe<CombatResultCommittedEventV3>(
'CombatResultCommittedEventV3',
handler,
EventPriorityLevel.COMBAT_LOG,
);
const damageRequest = {
type: 'DamageRequestEvent' as const,
timestamp: Date.now(),
caster: attacker,
target: defender,
damageType: DamageType.PHYSICAL,
baseDamage: 100,
finalDamage: 100,
};
EventBus.instance.publish(damageRequest);
expect(damageRequest.damageReductionPctBucket).toBe(0.5);
expect(committed.map((event) => event.result)).toContainEqual({
type: 'mechanic',
code: 'conditional_damage_modifier_trigger',
payload: { kind: 'named_trigger', label: '金甲' },
});
EventBus.instance.unsubscribe<CombatResultCommittedEventV3>(
'CombatResultCommittedEventV3',
handler,
);
});
it('魔法盾应消耗法力并吸收 98% 伤害', () => {
const attacker = createUnit('attacker', '进攻者');
const defender = createUnit('defender', '护盾者');
@@ -26,19 +26,41 @@ export class AffixEffectTranslator {
* 翻译词缀定义 + 品质 → 具体 EffectConfig
*/
translate(affix: RolledAffix, quality: Quality): EffectConfig {
const resolved = this.withDeathPreventTriggerKey(
this.resolveTemplate(
affix.effectTemplate,
QUALITY_ORDER[quality],
affix.finalMultiplier,
),
affix.id,
);
return this.withConditions(
affix,
this.withDeathPreventTriggerKey(
this.resolveTemplate(
affix.effectTemplate,
QUALITY_ORDER[quality],
affix.finalMultiplier,
),
affix.id,
),
this.withConditionalTriggerName(affix, resolved),
);
}
private withConditionalTriggerName(
affix: RolledAffix,
effect: EffectConfig,
): EffectConfig {
if (
effect.type !== 'percent_damage_modifier' ||
!affix.effectTemplate.conditions?.some(
(condition) => condition.type === 'chance',
)
) {
return effect;
}
return {
...effect,
params: {
...effect.params,
logTriggerName: affix.name,
},
};
}
private withConditions(def: RolledAffix, effect: EffectConfig): EffectConfig {
const conditions = def.effectTemplate.conditions;
const globalUnique = def.globalUnique;
@@ -73,6 +73,7 @@ describe('creation-v2 affix match contract', () => {
params: { tag: GameplayTags.ABILITY.FUNCTION.CONTROL },
},
]);
expect(result.params).not.toHaveProperty('logTriggerName');
});
it('多 debuff 协同应显式声明 debuff_count_at_least 条件', () => {
@@ -108,6 +109,33 @@ describe('creation-v2 affix match contract', () => {
params: { value: 2 },
},
]);
expect(result.params).not.toHaveProperty('logTriggerName');
});
it('概率条件词条应携带触发日志名称,确定性条件不应刷屏', () => {
const rolledAffix: RolledAffix = {
id: 'test-probability-trigger',
name: '概率护体',
slot: 'modifier',
rarity: 'rare',
energyCost: 10,
rollScore: 1,
rollEfficiency: 1,
finalMultiplier: 1,
isPerfect: false,
weight: 10,
match: {},
tags: [],
effectTemplate: {
type: 'percent_damage_modifier',
conditions: [{ type: 'chance', params: { value: 0.2 } }],
params: { mode: 'reduce', value: 0.2 },
},
};
expect(translator.translate(rolledAffix, '玄品').params).toMatchObject({
logTriggerName: '概率护体',
});
});
it('递归 effect 模板应解析为 battle-v5 EffectConfig', () => {
@@ -38,6 +38,8 @@ export interface CultivatorSectState {
joinedAt?: string;
activePathId?: SectPathId;
contribution: number;
/** Total contribution earned; unlike contribution, this is not reduced by spending. */
lifetimeContribution?: number;
discipleRank?: SectDiscipleRank;
office?: SectOffice;
promotedAt?: string;
@@ -17,6 +17,7 @@ describe('PromotionRequirementSpecification', () => {
realm: '筑基',
stage: '初期',
contribution: 500,
lifetimeContribution: 500,
dailyCompletions: 3,
completedTaskTags: new Set(['fixture.trial']),
},
@@ -30,6 +31,7 @@ describe('PromotionRequirementSpecification', () => {
realm: '炼气',
stage: '初期',
contribution: 10,
lifetimeContribution: 10,
dailyCompletions: 0,
completedTaskTags: new Set(),
},
@@ -43,4 +45,27 @@ describe('PromotionRequirementSpecification', () => {
'task:fixture.trial',
]);
});
it('uses lifetime contribution after the spendable balance is consumed', () => {
const specification = new PromotionRequirementSpecification();
expect(
specification.violations(
{
realm: '筑基',
stage: '初期',
contribution: 0,
lifetimeContribution: 500,
dailyCompletions: 3,
completedTaskTags: new Set(['fixture.trial']),
},
{
rank: 'inner',
minRealm: '筑基',
contribution: 500,
dailyCompletions: 3,
requiredTaskTags: [{ tag: 'fixture.trial', label: '完成夹具试炼' }],
},
),
).toEqual([]);
});
});
@@ -24,6 +24,7 @@ export interface PromotionCandidateFacts {
realm: RealmType;
stage: RealmStage;
contribution: number;
lifetimeContribution?: number;
dailyCompletions: number;
completedTaskTags: ReadonlySet<string>;
}
@@ -45,10 +46,12 @@ export class PromotionRequirementSpecification {
code: 'realm',
message: `境界达到${requirement.minRealm}`,
});
if (candidate.contribution < requirement.contribution)
const lifetimeContribution =
candidate.lifetimeContribution ?? candidate.contribution;
if (lifetimeContribution < requirement.contribution)
violations.push({
code: 'contribution',
message: `当前贡献达到${requirement.contribution}`,
message: `累计贡献达到${requirement.contribution}`,
});
if (
requirement.dailyCompletions &&