feat: port scenario 903 select-pool flow
This commit is contained in:
@@ -24,6 +24,7 @@ export interface GatewayUserInfo {
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
createdAt?: string;
|
||||
legacyMemberNo?: number;
|
||||
}
|
||||
|
||||
export interface GameSessionTokenPayload {
|
||||
@@ -82,7 +83,9 @@ const parsePayload = (value: unknown): GameSessionTokenPayload | null => {
|
||||
typeof user.id !== 'string' ||
|
||||
typeof user.username !== 'string' ||
|
||||
typeof user.displayName !== 'string' ||
|
||||
!Array.isArray(user.roles)
|
||||
!Array.isArray(user.roles) ||
|
||||
(user.legacyMemberNo !== undefined &&
|
||||
(!Number.isSafeInteger(user.legacyMemberNo) || user.legacyMemberNo <= 0))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -204,6 +204,22 @@ export type TurnDaemonCommand =
|
||||
specialWar?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'selectPoolCreate';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
ownerDisplayName: string;
|
||||
uniqueName: string;
|
||||
personality: string;
|
||||
seedOwnerIdentity: string | number;
|
||||
}
|
||||
| {
|
||||
type: 'selectPoolReselect';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
ownerDisplayName: string;
|
||||
uniqueName: string;
|
||||
}
|
||||
| {
|
||||
type: 'auctionBid';
|
||||
requestId?: string;
|
||||
@@ -460,6 +476,28 @@ export type TurnDaemonCommandResult =
|
||||
generalId: number;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'selectPoolCreate';
|
||||
ok: true;
|
||||
generalId: number;
|
||||
}
|
||||
| {
|
||||
type: 'selectPoolCreate';
|
||||
ok: false;
|
||||
code: 'BAD_REQUEST' | 'PRECONDITION_FAILED' | 'CONFLICT' | 'INTERNAL_SERVER_ERROR';
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'selectPoolReselect';
|
||||
ok: true;
|
||||
generalId: number;
|
||||
}
|
||||
| {
|
||||
type: 'selectPoolReselect';
|
||||
ok: false;
|
||||
code: 'BAD_REQUEST' | 'PRECONDITION_FAILED' | 'CONFLICT' | 'INTERNAL_SERVER_ERROR';
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'auctionBid';
|
||||
ok: true;
|
||||
|
||||
@@ -198,6 +198,19 @@ model General {
|
||||
@@map("general")
|
||||
}
|
||||
|
||||
model SelectPoolEntry {
|
||||
id Int @id @default(autoincrement())
|
||||
uniqueName String @unique @map("unique_name") @db.VarChar(20)
|
||||
ownerUserId String? @map("owner_user_id")
|
||||
generalId Int? @unique @map("general_id")
|
||||
reservedUntil DateTime? @map("reserved_until")
|
||||
info Json
|
||||
|
||||
@@index([ownerUserId])
|
||||
@@index([reservedUntil, generalId])
|
||||
@@map("select_pool")
|
||||
}
|
||||
|
||||
model GeneralAccessLog {
|
||||
id Int @id @default(autoincrement())
|
||||
generalId Int @unique @map("general_id")
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE "select_pool" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"unique_name" VARCHAR(20) NOT NULL,
|
||||
"owner_user_id" TEXT,
|
||||
"general_id" INTEGER,
|
||||
"reserved_until" TIMESTAMP(3),
|
||||
"info" JSONB NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "select_pool_unique_name_key"
|
||||
ON "select_pool"("unique_name");
|
||||
CREATE UNIQUE INDEX "select_pool_general_id_key"
|
||||
ON "select_pool"("general_id");
|
||||
CREATE INDEX "select_pool_owner_user_id_idx"
|
||||
ON "select_pool"("owner_user_id");
|
||||
CREATE INDEX "select_pool_reserved_until_general_id_idx"
|
||||
ON "select_pool"("reserved_until", "general_id");
|
||||
@@ -6,6 +6,7 @@ export interface DatabaseClient {
|
||||
$executeRaw: GamePrismaClient['$executeRaw'];
|
||||
worldState: GamePrisma.WorldStateDelegate;
|
||||
general: GamePrisma.GeneralDelegate;
|
||||
selectPoolEntry: GamePrisma.SelectPoolEntryDelegate;
|
||||
generalAccessLog: GamePrisma.GeneralAccessLogDelegate;
|
||||
trafficPeriod: GamePrisma.TrafficPeriodDelegate;
|
||||
trafficPeriodGeneral: GamePrisma.TrafficPeriodGeneralDelegate;
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface TurnEngineGeneralRow {
|
||||
deadYear: number;
|
||||
affinity: number | null;
|
||||
picture: string | null;
|
||||
imageServer: number;
|
||||
meta: JsonValue;
|
||||
penalty: JsonValue;
|
||||
turnTime: Date;
|
||||
@@ -193,6 +194,8 @@ export interface TurnEngineGeneralUpdateInput {
|
||||
bornYear?: number;
|
||||
deadYear?: number;
|
||||
picture: string | null;
|
||||
imageServer: number;
|
||||
startAge: number;
|
||||
horseCode: string;
|
||||
weaponCode: string;
|
||||
bookCode: string;
|
||||
@@ -208,6 +211,7 @@ export interface TurnEngineGeneralUpdateInput {
|
||||
|
||||
export interface TurnEngineGeneralCreateManyInput {
|
||||
id: number;
|
||||
userId?: string | null;
|
||||
name: string;
|
||||
nationId: number;
|
||||
cityId: number;
|
||||
@@ -241,6 +245,8 @@ export interface TurnEngineGeneralCreateManyInput {
|
||||
bornYear?: number;
|
||||
deadYear?: number;
|
||||
picture?: string | null;
|
||||
imageServer?: number;
|
||||
startAge?: number;
|
||||
lastTurn?: InputJsonValue;
|
||||
penalty?: InputJsonValue;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ import { createOfficerLevelActionModules } from './officerLevel.js';
|
||||
import {
|
||||
createTraitCatalog,
|
||||
DOMESTIC_TRAIT_KEYS,
|
||||
EVENT_DOMESTIC_TRAIT_KEYS,
|
||||
loadDomesticTraitModules,
|
||||
loadEventDomesticTraitModules,
|
||||
loadNationTraitModules,
|
||||
loadPersonalityTraitModules,
|
||||
loadWarTraitModules,
|
||||
@@ -87,14 +89,20 @@ export const loadActionModuleBundle = async <TriggerState extends GeneralTrigger
|
||||
unitSet?: UnitSetDefinition,
|
||||
scenarioEffect?: ScenarioEffectKey | null
|
||||
): Promise<ActionModuleBundle<TriggerState>> => {
|
||||
const [domestic, war, personality, nation, itemModules] = await Promise.all([
|
||||
const [domestic, eventDomestic, war, personality, nation, itemModules] = await Promise.all([
|
||||
loadDomesticTraitModules([...DOMESTIC_TRAIT_KEYS]),
|
||||
loadEventDomesticTraitModules([...EVENT_DOMESTIC_TRAIT_KEYS]),
|
||||
loadWarTraitModules([...WAR_TRAIT_KEYS]),
|
||||
loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS]),
|
||||
loadNationTraitModules([...NATION_TRAIT_KEYS]),
|
||||
loadItemModules([...ITEM_KEYS]) as Promise<ItemModule<TriggerState>[]>,
|
||||
]);
|
||||
const traitCatalog = createTraitCatalog<TriggerState>({ domestic, war, personality, nation });
|
||||
const traitCatalog = createTraitCatalog<TriggerState>({
|
||||
domestic: [...domestic, ...eventDomestic],
|
||||
war,
|
||||
personality,
|
||||
nation,
|
||||
});
|
||||
const officer = createOfficerLevelActionModules<TriggerState>();
|
||||
const items = createItemActionModules(createItemModuleRegistry(itemModules));
|
||||
const inherit = createInheritBuffModules();
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
|
||||
import {
|
||||
isWarTraitKey,
|
||||
type WarTraitKey,
|
||||
WarTraitLoader,
|
||||
} from '@sammo-ts/logic/actionModules/traits/war/index.js';
|
||||
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||
import { che_부상무효 } from '@sammo-ts/logic/war/triggers/che_견고.js';
|
||||
|
||||
export const EVENT_DOMESTIC_TRAIT_KEYS = [
|
||||
'che_event_귀병',
|
||||
'che_event_신산',
|
||||
'che_event_환술',
|
||||
'che_event_집중',
|
||||
'che_event_신중',
|
||||
'che_event_반계',
|
||||
'che_event_보병',
|
||||
'che_event_궁병',
|
||||
'che_event_기병',
|
||||
'che_event_공성',
|
||||
'che_event_돌격',
|
||||
'che_event_무쌍',
|
||||
'che_event_견고',
|
||||
'che_event_위압',
|
||||
'che_event_저격',
|
||||
'che_event_필살',
|
||||
'che_event_징병',
|
||||
'che_event_의술',
|
||||
'che_event_격노',
|
||||
'che_event_척사',
|
||||
] as const;
|
||||
|
||||
export type EventDomesticTraitKey = (typeof EVENT_DOMESTIC_TRAIT_KEYS)[number];
|
||||
export type EventDomesticTraitModule = TraitModule;
|
||||
export const EVENT_GYEONGO_RAISE_TYPE = BaseWarUnitTrigger.TYPE_ITEM;
|
||||
|
||||
export const isEventDomesticTraitKey = (value: string): value is EventDomesticTraitKey =>
|
||||
EVENT_DOMESTIC_TRAIT_KEYS.includes(value as EventDomesticTraitKey);
|
||||
|
||||
const resolveWarKey = (key: EventDomesticTraitKey): WarTraitKey => {
|
||||
const warKey = key.replace(/^che_event_/, 'che_');
|
||||
if (!isWarTraitKey(warKey)) {
|
||||
throw new Error(`Event domestic trait has no canonical war trait: ${key}`);
|
||||
}
|
||||
return warKey;
|
||||
};
|
||||
|
||||
const withRefEventOverrides = (
|
||||
key: EventDomesticTraitKey,
|
||||
canonical: TraitModule
|
||||
): EventDomesticTraitModule => {
|
||||
const { selection: _selection, ...behavior } = canonical;
|
||||
const alias: EventDomesticTraitModule = {
|
||||
...behavior,
|
||||
key,
|
||||
kind: 'domestic',
|
||||
};
|
||||
|
||||
if (key === 'che_event_무쌍' && canonical.getWarPowerMultiplier) {
|
||||
alias.getWarPowerMultiplier = (context, unit, oppose) => {
|
||||
const general =
|
||||
'getGeneral' in unit &&
|
||||
typeof (unit as { getGeneral?: unknown }).getGeneral === 'function'
|
||||
? (
|
||||
unit as typeof unit & {
|
||||
getGeneral: () => { role: { specialWar: string | null } };
|
||||
}
|
||||
).getGeneral()
|
||||
: null;
|
||||
return general?.role.specialWar === canonical.key
|
||||
? [1, 1]
|
||||
: canonical.getWarPowerMultiplier!(context, unit, oppose);
|
||||
};
|
||||
}
|
||||
|
||||
if (key === 'che_event_견고') {
|
||||
alias.getBattleInitTriggerList = (context) => {
|
||||
if (!context.unit) return null;
|
||||
return new WarTriggerCaller(
|
||||
new che_부상무효(context.unit, EVENT_GYEONGO_RAISE_TYPE)
|
||||
);
|
||||
};
|
||||
alias.getBattlePhaseTriggerList = (context) => {
|
||||
if (!context.unit) return null;
|
||||
return new WarTriggerCaller(
|
||||
new che_부상무효(context.unit, EVENT_GYEONGO_RAISE_TYPE)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return alias;
|
||||
};
|
||||
|
||||
export class EventDomesticTraitLoader {
|
||||
private readonly cache = new Map<EventDomesticTraitKey, Promise<EventDomesticTraitModule>>();
|
||||
|
||||
constructor(private readonly warLoader = new WarTraitLoader()) {}
|
||||
|
||||
async load(key: EventDomesticTraitKey): Promise<EventDomesticTraitModule> {
|
||||
const cached = this.cache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const loading = this.warLoader
|
||||
.load(resolveWarKey(key))
|
||||
.then((canonical) => withRefEventOverrides(key, canonical));
|
||||
this.cache.set(key, loading);
|
||||
return loading;
|
||||
}
|
||||
}
|
||||
|
||||
export const loadEventDomesticTraitModules = async (
|
||||
keys: EventDomesticTraitKey[],
|
||||
loader = new EventDomesticTraitLoader()
|
||||
): Promise<EventDomesticTraitModule[]> => {
|
||||
const modules: EventDomesticTraitModule[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const key of keys) {
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
modules.push(await loader.load(key));
|
||||
}
|
||||
return modules;
|
||||
};
|
||||
@@ -3,6 +3,7 @@ export * from './requirements.js';
|
||||
export * from './selector.js';
|
||||
export * from './catalog.js';
|
||||
export * from './domestic/index.js';
|
||||
export * from './eventDomestic/index.js';
|
||||
export * from './war/index.js';
|
||||
export * from './personality/index.js';
|
||||
export * from './nation/index.js';
|
||||
|
||||
@@ -3,9 +3,12 @@ import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules
|
||||
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
|
||||
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
|
||||
import { TraitRequirement, TraitWeightType } from '../requirements.js';
|
||||
import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||
import { che_부상무효 } from '@sammo-ts/logic/war/triggers/che_견고.js';
|
||||
|
||||
export const GYEONGO_RAISE_TYPE =
|
||||
BaseWarUnitTrigger.TYPE_NONE + BaseWarUnitTrigger.TYPE_DEDUP_TYPE_BASE * 404;
|
||||
|
||||
function onCalcStat(context: GeneralActionContext, statName: GeneralStatName, value: number, aux?: unknown): number;
|
||||
function onCalcStat(
|
||||
context: WarActionContext,
|
||||
@@ -45,11 +48,11 @@ export const traitModule: TraitModule = {
|
||||
}) as TraitModule['onCalcOpposeStat'],
|
||||
getBattleInitTriggerList: (_context) => {
|
||||
if (!_context.unit) return null;
|
||||
return new WarTriggerCaller(new che_부상무효(_context.unit));
|
||||
return new WarTriggerCaller(new che_부상무효(_context.unit, GYEONGO_RAISE_TYPE));
|
||||
},
|
||||
getBattlePhaseTriggerList: (_context) => {
|
||||
if (!_context.unit) return null;
|
||||
return new WarTriggerCaller(new che_부상무효(_context.unit));
|
||||
return new WarTriggerCaller(new che_부상무효(_context.unit, GYEONGO_RAISE_TYPE));
|
||||
},
|
||||
getWarPowerMultiplier: (_context, _unit, _oppose) => {
|
||||
return [1, 0.9];
|
||||
|
||||
@@ -3,8 +3,8 @@ import { BaseWarUnitTrigger } from '@sammo-ts/logic/war/triggers.js';
|
||||
import type { WarUnit } from '@sammo-ts/logic/war/units.js';
|
||||
|
||||
export class che_부상무효 extends BaseWarUnitTrigger {
|
||||
constructor(unit: WarUnit) {
|
||||
super(unit, TriggerPriority.Begin + 200);
|
||||
constructor(unit: WarUnit, raiseType = BaseWarUnitTrigger.TYPE_NONE) {
|
||||
super(unit, TriggerPriority.Begin + 200, raiseType);
|
||||
}
|
||||
|
||||
protected actionWar(
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
DOMESTIC_TRAIT_KEYS,
|
||||
EVENT_GYEONGO_RAISE_TYPE,
|
||||
EVENT_DOMESTIC_TRAIT_KEYS,
|
||||
isWarTraitKey,
|
||||
loadEventDomesticTraitModules,
|
||||
type WarTraitKey,
|
||||
WarTraitLoader,
|
||||
} from '../src/actionModules/traits/index.js';
|
||||
import { GYEONGO_RAISE_TYPE } from '../src/actionModules/traits/war/che_견고.js';
|
||||
import { createWarTriggerEnv } from '../src/war/triggers.js';
|
||||
import type { WarActionContext } from '../src/war/actions.js';
|
||||
import type { WarUnit } from '../src/war/units.js';
|
||||
|
||||
const canonicalKey = (eventKey: string): WarTraitKey => {
|
||||
const key = eventKey.replace(/^che_event_/, 'che_');
|
||||
if (!isWarTraitKey(key)) {
|
||||
throw new Error(`Missing canonical war trait for ${eventKey}`);
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
describe('Ref event domestic traits', () => {
|
||||
it('loads all 20 exact DB keys without contaminating ordinary domestic selection keys', async () => {
|
||||
const modules = await loadEventDomesticTraitModules([...EVENT_DOMESTIC_TRAIT_KEYS]);
|
||||
const warLoader = new WarTraitLoader();
|
||||
|
||||
expect(modules).toHaveLength(20);
|
||||
expect(DOMESTIC_TRAIT_KEYS).toHaveLength(8);
|
||||
expect(DOMESTIC_TRAIT_KEYS.some((key) => key.startsWith('che_event_'))).toBe(false);
|
||||
for (const module of modules) {
|
||||
const canonical = await warLoader.load(canonicalKey(module.key));
|
||||
expect(module.key).toMatch(/^che_event_/);
|
||||
expect(module.kind).toBe('domestic');
|
||||
expect(module.name).toBe(canonical.name);
|
||||
expect(module.info).toBe(canonical.info);
|
||||
expect(module.getName?.()).toBe(canonical.getName?.());
|
||||
expect(module.getInfo?.()).toBe(canonical.getInfo?.());
|
||||
expect(module.selection).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('suppresses only the duplicate event multiplier for dual-slot 무쌍', async () => {
|
||||
const [eventMusang] = await loadEventDomesticTraitModules(['che_event_무쌍']);
|
||||
const unit = {
|
||||
getGeneral: () => ({
|
||||
role: { specialWar: 'che_무쌍' },
|
||||
meta: { rank_killnum: 40 },
|
||||
}),
|
||||
} as unknown as WarUnit;
|
||||
const context = { unit } as unknown as WarActionContext;
|
||||
|
||||
expect(eventMusang!.getWarPowerMultiplier?.(context, unit, unit)).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
it('keeps event and ordinary 견고 injury-prevention triggers distinct by raise type', async () => {
|
||||
const [eventGyeongo] = await loadEventDomesticTraitModules(['che_event_견고']);
|
||||
const canonical = await new WarTraitLoader().load('che_견고');
|
||||
const activated: string[] = [];
|
||||
const unit = {
|
||||
getUnitId: () => 7,
|
||||
isAttacker: () => true,
|
||||
activateSkill: (name: string) => activated.push(name),
|
||||
} as unknown as WarUnit;
|
||||
const oppose = {
|
||||
getUnitId: () => 8,
|
||||
isAttacker: () => false,
|
||||
} as unknown as WarUnit;
|
||||
const context = { unit } as unknown as WarActionContext;
|
||||
const caller = canonical.getBattleInitTriggerList?.(context);
|
||||
caller?.merge(eventGyeongo!.getBattleInitTriggerList?.(context));
|
||||
caller?.fire(
|
||||
{ rng: null as never, attacker: unit, defender: oppose },
|
||||
createWarTriggerEnv()
|
||||
);
|
||||
|
||||
expect(GYEONGO_RAISE_TYPE).toBe(413696);
|
||||
expect(EVENT_GYEONGO_RAISE_TYPE).toBe(1);
|
||||
expect(activated).toEqual(['부상무효', '부상무효']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user