feat: port scenario 903 select-pool flow

This commit is contained in:
2026-07-30 23:27:59 +00:00
parent 9bd057456b
commit 115218ded8
80 changed files with 6859 additions and 48 deletions
+4 -1
View File
@@ -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;
}
+38
View File
@@ -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;
+13
View File
@@ -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");
+1
View File
@@ -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;
+6
View File
@@ -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;
}
+10 -2
View File
@@ -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(['부상무효', '부상무효']);
});
});