feat: complete legacy unique item battle parity
This commit is contained in:
@@ -25,6 +25,8 @@ import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/ac
|
|||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
|
||||||
|
import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js';
|
||||||
|
|
||||||
export interface AgitateResolveContext<
|
export interface AgitateResolveContext<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
@@ -44,6 +46,11 @@ export class ActionResolver<
|
|||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionResolver<TriggerState, AgitateArgs> {
|
> implements GeneralActionResolver<TriggerState, AgitateArgs> {
|
||||||
readonly key = ACTION_KEY;
|
readonly key = ACTION_KEY;
|
||||||
|
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||||
|
|
||||||
|
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined> = []) {
|
||||||
|
this.pipeline = new GeneralActionPipeline(modules);
|
||||||
|
}
|
||||||
|
|
||||||
resolve(context: GeneralActionResolveContext<TriggerState>, args: AgitateArgs): GeneralActionOutcome<TriggerState> {
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: AgitateArgs): GeneralActionOutcome<TriggerState> {
|
||||||
const ctx = context as AgitateResolveContext<TriggerState>;
|
const ctx = context as AgitateResolveContext<TriggerState>;
|
||||||
@@ -86,8 +93,8 @@ export class ActionResolver<
|
|||||||
1
|
1
|
||||||
)}</>만큼 감소하고, 장수 <C>${injuryCount}</>명이 부상 당했습니다.`,
|
)}</>만큼 감소하고, 장수 <C>${injuryCount}</>명이 부상 당했습니다.`,
|
||||||
{
|
{
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -107,6 +114,8 @@ export class ActionResolver<
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
consumeSuccessfulStrategyItem(this.pipeline, context);
|
||||||
|
|
||||||
// General Update (Cost + Exp + LeaderExp)
|
// General Update (Cost + Exp + LeaderExp)
|
||||||
effects.push(
|
effects.push(
|
||||||
createGeneralPatchEffect(
|
createGeneralPatchEffect(
|
||||||
@@ -137,8 +146,10 @@ export class ActionDefinition<
|
|||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
private readonly resolver: ActionResolver<TriggerState>;
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
constructor(_env: TurnCommandEnv) {
|
constructor(env: TurnCommandEnv) {
|
||||||
this.resolver = new ActionResolver();
|
this.resolver = new ActionResolver<TriggerState>(
|
||||||
|
(env.generalActionModules ?? []) as GeneralActionModule<TriggerState>[]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
parseArgs(raw: unknown): AgitateArgs | null {
|
parseArgs(raw: unknown): AgitateArgs | null {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/ac
|
|||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
|
||||||
|
import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js';
|
||||||
|
|
||||||
export interface SeizeResolveContext<
|
export interface SeizeResolveContext<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
@@ -50,6 +52,11 @@ export class ActionResolver<
|
|||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionResolver<TriggerState, SeizeArgs> {
|
> implements GeneralActionResolver<TriggerState, SeizeArgs> {
|
||||||
readonly key = ACTION_KEY;
|
readonly key = ACTION_KEY;
|
||||||
|
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||||
|
|
||||||
|
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined> = []) {
|
||||||
|
this.pipeline = new GeneralActionPipeline(modules);
|
||||||
|
}
|
||||||
|
|
||||||
resolve(context: GeneralActionResolveContext<TriggerState>, args: SeizeArgs): GeneralActionOutcome<TriggerState> {
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: SeizeArgs): GeneralActionOutcome<TriggerState> {
|
||||||
const ctx = context as SeizeResolveContext<TriggerState>;
|
const ctx = context as SeizeResolveContext<TriggerState>;
|
||||||
@@ -166,6 +173,8 @@ export class ActionResolver<
|
|||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
consumeSuccessfulStrategyItem(this.pipeline, context);
|
||||||
|
|
||||||
effects.push(
|
effects.push(
|
||||||
createGeneralPatchEffect(
|
createGeneralPatchEffect(
|
||||||
{
|
{
|
||||||
@@ -195,8 +204,10 @@ export class ActionDefinition<
|
|||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
private readonly resolver: ActionResolver<TriggerState>;
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
constructor(_env: TurnCommandEnv) {
|
constructor(env: TurnCommandEnv) {
|
||||||
this.resolver = new ActionResolver();
|
this.resolver = new ActionResolver<TriggerState>(
|
||||||
|
(env.generalActionModules ?? []) as GeneralActionModule<TriggerState>[]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
parseArgs(raw: unknown): SeizeArgs | null {
|
parseArgs(raw: unknown): SeizeArgs | null {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/ac
|
|||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
|
||||||
|
import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js';
|
||||||
|
|
||||||
export interface DestroyResolveContext<
|
export interface DestroyResolveContext<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
@@ -44,6 +46,11 @@ export class ActionResolver<
|
|||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionResolver<TriggerState, DestroyArgs> {
|
> implements GeneralActionResolver<TriggerState, DestroyArgs> {
|
||||||
readonly key = ACTION_KEY;
|
readonly key = ACTION_KEY;
|
||||||
|
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||||
|
|
||||||
|
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined> = []) {
|
||||||
|
this.pipeline = new GeneralActionPipeline(modules);
|
||||||
|
}
|
||||||
|
|
||||||
resolve(context: GeneralActionResolveContext<TriggerState>, args: DestroyArgs): GeneralActionOutcome<TriggerState> {
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: DestroyArgs): GeneralActionOutcome<TriggerState> {
|
||||||
const ctx = context as DestroyResolveContext<TriggerState>;
|
const ctx = context as DestroyResolveContext<TriggerState>;
|
||||||
@@ -102,6 +109,8 @@ export class ActionResolver<
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
consumeSuccessfulStrategyItem(this.pipeline, context);
|
||||||
|
|
||||||
// General Update (Cost + Exp)
|
// General Update (Cost + Exp)
|
||||||
effects.push(
|
effects.push(
|
||||||
createGeneralPatchEffect(
|
createGeneralPatchEffect(
|
||||||
@@ -132,8 +141,10 @@ export class ActionDefinition<
|
|||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
private readonly resolver: ActionResolver<TriggerState>;
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
constructor(_env: TurnCommandEnv) {
|
constructor(env: TurnCommandEnv) {
|
||||||
this.resolver = new ActionResolver();
|
this.resolver = new ActionResolver<TriggerState>(
|
||||||
|
(env.generalActionModules ?? []) as GeneralActionModule<TriggerState>[]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
parseArgs(raw: unknown): DestroyArgs | null {
|
parseArgs(raw: unknown): DestroyArgs | null {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action
|
|||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { clamp } from 'es-toolkit';
|
import { clamp } from 'es-toolkit';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js';
|
||||||
|
|
||||||
export interface FireAttackEnvironment {
|
export interface FireAttackEnvironment {
|
||||||
develCost: number;
|
develCost: number;
|
||||||
@@ -251,9 +252,11 @@ export class ActionResolver<
|
|||||||
> implements GeneralActionResolver<TriggerState, FireAttackArgs> {
|
> implements GeneralActionResolver<TriggerState, FireAttackArgs> {
|
||||||
readonly key = 'che_화계';
|
readonly key = 'che_화계';
|
||||||
private readonly command: CommandResolver<TriggerState>;
|
private readonly command: CommandResolver<TriggerState>;
|
||||||
|
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||||
|
|
||||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, env: FireAttackEnvironment) {
|
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, env: FireAttackEnvironment) {
|
||||||
this.command = new CommandResolver(modules, env);
|
this.command = new CommandResolver(modules, env);
|
||||||
|
this.pipeline = new GeneralActionPipeline(modules);
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(
|
resolve(
|
||||||
@@ -334,7 +337,7 @@ export class ActionResolver<
|
|||||||
context.addLog(
|
context.addLog(
|
||||||
`<G><b>${context.destCity.name}</b></>에 ${commandName}${JosaUtil.pick(commandName, '이')} 성공했습니다.`,
|
`<G><b>${context.destCity.name}</b></>에 ${commandName}${JosaUtil.pick(commandName, '이')} 성공했습니다.`,
|
||||||
{
|
{
|
||||||
format: LogFormat.MONTH,
|
format: LogFormat.MONTH,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
context.addLog(
|
context.addLog(
|
||||||
@@ -345,7 +348,9 @@ export class ActionResolver<
|
|||||||
);
|
);
|
||||||
|
|
||||||
const itemCode = general.role.items.item;
|
const itemCode = general.role.items.item;
|
||||||
if (typeof itemCode === 'string' && itemCode.length > 0) {
|
const actionResult = consumeSuccessfulStrategyItem(this.pipeline, context);
|
||||||
|
const consumedItems = Array.isArray(actionResult?.['consumedItems']) ? actionResult['consumedItems'] : [];
|
||||||
|
if (typeof itemCode === 'string' && consumedItems.includes(itemCode)) {
|
||||||
context.addLog(`<C>${itemCode}</>${JosaUtil.pick(itemCode, '을')} 사용!`, {
|
context.addLog(`<C>${itemCode}</>${JosaUtil.pick(itemCode, '을')} 사용!`, {
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
});
|
});
|
||||||
@@ -383,13 +388,7 @@ export class ActionDefinition<
|
|||||||
|
|
||||||
buildMinConstraints(_ctx: ConstraintContext, _args: FireAttackArgs): Constraint[] {
|
buildMinConstraints(_ctx: ConstraintContext, _args: FireAttackArgs): Constraint[] {
|
||||||
const { gold, rice } = this.command.getCost();
|
const { gold, rice } = this.command.getCost();
|
||||||
return [
|
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => gold), reqGeneralRice(() => rice)];
|
||||||
notBeNeutral(),
|
|
||||||
occupiedCity(),
|
|
||||||
suppliedCity(),
|
|
||||||
reqGeneralGold(() => gold),
|
|
||||||
reqGeneralRice(() => rice),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buildConstraints(_ctx: ConstraintContext, _args: FireAttackArgs): Constraint[] {
|
buildConstraints(_ctx: ConstraintContext, _args: FireAttackArgs): Constraint[] {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
|
import type { GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
||||||
|
import type { GeneralActionPipeline } from '@sammo-ts/logic/triggers/general-action.js';
|
||||||
|
|
||||||
|
export const consumeSuccessfulStrategyItem = <TriggerState extends GeneralTriggerState>(
|
||||||
|
pipeline: GeneralActionPipeline<TriggerState>,
|
||||||
|
context: GeneralActionResolveContext<TriggerState>
|
||||||
|
): Record<string, unknown> | null =>
|
||||||
|
pipeline.onArbitraryAction(context, 'GeneralCommand', null, {
|
||||||
|
command: '계략',
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ export const itemModule: ItemModule = {
|
|||||||
consumable: true,
|
consumable: true,
|
||||||
reqSecu: 1000,
|
reqSecu: 1000,
|
||||||
unique: false,
|
unique: false,
|
||||||
|
consumeOn: [{ actionType: 'GeneralCommand', command: '계략', successOnly: true }],
|
||||||
onCalcDomestic: (_context, turnType, varType, value) => {
|
onCalcDomestic: (_context, turnType, varType, value) => {
|
||||||
if (turnType === '계략' && varType === 'success') {
|
if (turnType === '계략' && varType === 'success') {
|
||||||
return value + 0.2;
|
return value + 0.2;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const itemModule: ItemModule = {
|
|||||||
consumable: true,
|
consumable: true,
|
||||||
reqSecu: 2000,
|
reqSecu: 2000,
|
||||||
unique: false,
|
unique: false,
|
||||||
|
consumeOn: [{ actionType: 'GeneralCommand', command: '계략', successOnly: true }],
|
||||||
onCalcDomestic: (_context, turnType, varType, value) => {
|
onCalcDomestic: (_context, turnType, varType, value) => {
|
||||||
if (turnType === '계략' && varType === 'success') {
|
if (turnType === '계략' && varType === 'success') {
|
||||||
return value + 0.5;
|
return value + 0.5;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const itemModule: ItemModule = createStatItemModule({
|
|||||||
slot: 'horse',
|
slot: 'horse',
|
||||||
statName: 'leadership',
|
statName: 'leadership',
|
||||||
statValue: 3,
|
statValue: 3,
|
||||||
cost: 4500,
|
cost: 6000,
|
||||||
buyable: true,
|
buyable: true,
|
||||||
reqSecu: 2500,
|
reqSecu: 3000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const itemModule: ItemModule = createStatItemModule({
|
|||||||
slot: 'horse',
|
slot: 'horse',
|
||||||
statName: 'leadership',
|
statName: 'leadership',
|
||||||
statValue: 4,
|
statValue: 4,
|
||||||
cost: 6000,
|
cost: 10000,
|
||||||
buyable: true,
|
buyable: true,
|
||||||
reqSecu: 3000,
|
reqSecu: 4000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const itemModule: ItemModule = createStatItemModule({
|
|||||||
slot: 'horse',
|
slot: 'horse',
|
||||||
statName: 'leadership',
|
statName: 'leadership',
|
||||||
statValue: 5,
|
statValue: 5,
|
||||||
cost: 7500,
|
cost: 15000,
|
||||||
buyable: true,
|
buyable: true,
|
||||||
reqSecu: 3500,
|
reqSecu: 5000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const itemModule: ItemModule = createStatItemModule({
|
|||||||
slot: 'weapon',
|
slot: 'weapon',
|
||||||
statName: 'strength',
|
statName: 'strength',
|
||||||
statValue: 3,
|
statValue: 3,
|
||||||
cost: 4500,
|
cost: 6000,
|
||||||
buyable: true,
|
buyable: true,
|
||||||
reqSecu: 2500,
|
reqSecu: 3000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const itemModule: ItemModule = createStatItemModule({
|
|||||||
slot: 'weapon',
|
slot: 'weapon',
|
||||||
statName: 'strength',
|
statName: 'strength',
|
||||||
statValue: 4,
|
statValue: 4,
|
||||||
cost: 6000,
|
cost: 10000,
|
||||||
buyable: true,
|
buyable: true,
|
||||||
reqSecu: 3000,
|
reqSecu: 4000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const itemModule: ItemModule = createStatItemModule({
|
|||||||
slot: 'weapon',
|
slot: 'weapon',
|
||||||
statName: 'strength',
|
statName: 'strength',
|
||||||
statValue: 5,
|
statValue: 5,
|
||||||
cost: 7500,
|
cost: 15000,
|
||||||
buyable: true,
|
buyable: true,
|
||||||
reqSecu: 3500,
|
reqSecu: 5000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const itemModule: ItemModule = createStatItemModule({
|
|||||||
slot: 'weapon',
|
slot: 'weapon',
|
||||||
statName: 'strength',
|
statName: 'strength',
|
||||||
statValue: 6,
|
statValue: 6,
|
||||||
cost: 9000,
|
cost: 21000,
|
||||||
buyable: true,
|
buyable: true,
|
||||||
reqSecu: 4000,
|
reqSecu: 6000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { createStatItemModule } from './base.js';
|
import { createStatItemModule } from './base.js';
|
||||||
import type { ItemModule } from './types.js';
|
import type { ItemModule } from './types.js';
|
||||||
|
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||||
|
import { che_저격발동, che_저격시도 } from '@sammo-ts/logic/war/triggers/che_저격.js';
|
||||||
|
|
||||||
export const itemModule: ItemModule = createStatItemModule({
|
const baseModule = createStatItemModule({
|
||||||
key: 'che_무기_07_맥궁',
|
key: 'che_무기_07_맥궁',
|
||||||
rawName: '맥궁',
|
rawName: '맥궁',
|
||||||
slot: 'weapon',
|
slot: 'weapon',
|
||||||
@@ -11,4 +13,18 @@ export const itemModule: ItemModule = createStatItemModule({
|
|||||||
buyable: false,
|
buyable: false,
|
||||||
reqSecu: 0,
|
reqSecu: 0,
|
||||||
unique: true,
|
unique: true,
|
||||||
|
extraInfo: '[전투] 새로운 상대와 전투 시 20% 확률로 저격 발동, 성공 시 사기+20',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const raiseType = BaseWarUnitTrigger.TYPE_ITEM + BaseWarUnitTrigger.TYPE_DEDUP_TYPE_BASE * 107;
|
||||||
|
|
||||||
|
export const itemModule: ItemModule = {
|
||||||
|
...baseModule,
|
||||||
|
getBattlePhaseTriggerList: (context) =>
|
||||||
|
context.unit
|
||||||
|
? new WarTriggerCaller(
|
||||||
|
new che_저격시도(context.unit, raiseType, 0.2, 20, 40),
|
||||||
|
new che_저격발동(context.unit, raiseType)
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { createStatItemModule } from './base.js';
|
import { createStatItemModule } from './base.js';
|
||||||
import type { ItemModule } from './types.js';
|
import type { ItemModule } from './types.js';
|
||||||
|
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||||
|
import { che_저격발동, che_저격시도 } from '@sammo-ts/logic/war/triggers/che_저격.js';
|
||||||
|
|
||||||
export const itemModule: ItemModule = createStatItemModule({
|
const baseModule = createStatItemModule({
|
||||||
key: 'che_무기_11_이광궁',
|
key: 'che_무기_11_이광궁',
|
||||||
rawName: '이광궁',
|
rawName: '이광궁',
|
||||||
slot: 'weapon',
|
slot: 'weapon',
|
||||||
@@ -11,4 +13,18 @@ export const itemModule: ItemModule = createStatItemModule({
|
|||||||
buyable: false,
|
buyable: false,
|
||||||
reqSecu: 0,
|
reqSecu: 0,
|
||||||
unique: true,
|
unique: true,
|
||||||
|
extraInfo: '[전투] 새로운 상대와 전투 시 10% 확률로 저격 발동, 성공 시 사기+10',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const raiseType = BaseWarUnitTrigger.TYPE_ITEM + BaseWarUnitTrigger.TYPE_DEDUP_TYPE_BASE * 111;
|
||||||
|
|
||||||
|
export const itemModule: ItemModule = {
|
||||||
|
...baseModule,
|
||||||
|
getBattlePhaseTriggerList: (context) =>
|
||||||
|
context.unit
|
||||||
|
? new WarTriggerCaller(
|
||||||
|
new che_저격시도(context.unit, raiseType, 0.1, 20, 40, 10),
|
||||||
|
new che_저격발동(context.unit, raiseType)
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { createStatItemModule } from './base.js';
|
import { createStatItemModule } from './base.js';
|
||||||
import type { ItemModule } from './types.js';
|
import type { ItemModule } from './types.js';
|
||||||
|
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||||
|
import { che_저격발동, che_저격시도 } from '@sammo-ts/logic/war/triggers/che_저격.js';
|
||||||
|
|
||||||
export const itemModule: ItemModule = createStatItemModule({
|
const baseModule = createStatItemModule({
|
||||||
key: 'che_무기_13_양유기궁',
|
key: 'che_무기_13_양유기궁',
|
||||||
rawName: '양유기궁',
|
rawName: '양유기궁',
|
||||||
slot: 'weapon',
|
slot: 'weapon',
|
||||||
@@ -11,4 +13,18 @@ export const itemModule: ItemModule = createStatItemModule({
|
|||||||
buyable: false,
|
buyable: false,
|
||||||
reqSecu: 0,
|
reqSecu: 0,
|
||||||
unique: true,
|
unique: true,
|
||||||
|
extraInfo: '[전투] 새로운 상대와 전투 시 10% 확률로 저격 발동, 성공 시 사기+10',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const raiseType = BaseWarUnitTrigger.TYPE_ITEM + BaseWarUnitTrigger.TYPE_DEDUP_TYPE_BASE * 113;
|
||||||
|
|
||||||
|
export const itemModule: ItemModule = {
|
||||||
|
...baseModule,
|
||||||
|
getBattlePhaseTriggerList: (context) =>
|
||||||
|
context.unit
|
||||||
|
? new WarTriggerCaller(
|
||||||
|
new che_저격시도(context.unit, raiseType, 0.1, 20, 40, 10),
|
||||||
|
new che_저격발동(context.unit, raiseType)
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { clamp } from '@sammo-ts/logic/war/utils.js';
|
import { clamp } from '@sammo-ts/logic/war/utils.js';
|
||||||
import { WarUnitGeneral } from '@sammo-ts/logic/war/units.js';
|
import { WarUnitGeneral } from '@sammo-ts/logic/war/units.js';
|
||||||
|
import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||||
|
import { 전투력보정 } from '@sammo-ts/logic/war/triggers/전투력보정.js';
|
||||||
import type { ItemModule } from './types.js';
|
import type { ItemModule } from './types.js';
|
||||||
|
|
||||||
const ITEM_KEY = 'che_불굴_상편';
|
const ITEM_KEY = 'che_불굴_상편';
|
||||||
@@ -15,14 +17,15 @@ export const itemModule: ItemModule = {
|
|||||||
consumable: false,
|
consumable: false,
|
||||||
reqSecu: 0,
|
reqSecu: 0,
|
||||||
unique: false,
|
unique: false,
|
||||||
getWarPowerMultiplier: (_context, unit, _oppose) => {
|
getBattlePhaseTriggerList: (context) => {
|
||||||
|
const unit = context.unit;
|
||||||
if (!(unit instanceof WarUnitGeneral)) {
|
if (!(unit instanceof WarUnitGeneral)) {
|
||||||
return [1, 1];
|
return null;
|
||||||
}
|
}
|
||||||
const general = unit.getGeneral();
|
const general = unit.getGeneral();
|
||||||
const leadership = general.stats.leadership;
|
const leadership = general.stats.leadership;
|
||||||
const crew = general.crew;
|
const crew = general.crew;
|
||||||
const crewRatio = clamp(crew / (leadership * 100), 0, 1);
|
const crewRatio = clamp(crew / (leadership * 100), 0, 1);
|
||||||
return [1 + 0.6 * (1 - crewRatio), 1];
|
return new WarTriggerCaller(new 전투력보정(unit, 1 + 0.6 * (1 - crewRatio)));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { ItemModule } from './types.js';
|
import type { ItemModule } from './types.js';
|
||||||
|
import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||||
|
import { 전투력보정 } from '@sammo-ts/logic/war/triggers/전투력보정.js';
|
||||||
|
|
||||||
const ITEM_KEY = 'che_상성보정_과실주';
|
const ITEM_KEY = 'che_상성보정_과실주';
|
||||||
|
|
||||||
@@ -13,11 +15,16 @@ export const itemModule: ItemModule = {
|
|||||||
consumable: false,
|
consumable: false,
|
||||||
reqSecu: 0,
|
reqSecu: 0,
|
||||||
unique: false,
|
unique: false,
|
||||||
getWarPowerMultiplier: (_context, unit, oppose) => {
|
getBattlePhaseTriggerList: (context) => {
|
||||||
|
const unit = context.unit;
|
||||||
|
const oppose = unit?.getOppose();
|
||||||
|
if (!unit || !oppose) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const attackCoef = unit.getCrewType().getAttackCoef(oppose.getCrewType());
|
const attackCoef = unit.getCrewType().getAttackCoef(oppose.getCrewType());
|
||||||
if (attackCoef < 1) {
|
if (attackCoef < 1) {
|
||||||
return [1, 1];
|
return null;
|
||||||
}
|
}
|
||||||
return [1.1, 0.9];
|
return new WarTriggerCaller(new 전투력보정(unit, 1.1, 0.9));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ const baseModule = createStatItemModule({
|
|||||||
slot: 'book',
|
slot: 'book',
|
||||||
statName: 'intelligence',
|
statName: 'intelligence',
|
||||||
statValue: STAT_VALUE,
|
statValue: STAT_VALUE,
|
||||||
cost: 6000,
|
cost: 10000,
|
||||||
buyable: true,
|
buyable: true,
|
||||||
reqSecu: 3000,
|
reqSecu: 4000,
|
||||||
extraInfo: '[전투] 계략 시도 확률 +2%p',
|
extraInfo: '[전투] 계략 시도 확률 +2%p',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ const baseModule = createStatItemModule({
|
|||||||
slot: 'book',
|
slot: 'book',
|
||||||
statName: 'intelligence',
|
statName: 'intelligence',
|
||||||
statValue: STAT_VALUE,
|
statValue: STAT_VALUE,
|
||||||
cost: 7500,
|
cost: 15000,
|
||||||
buyable: true,
|
buyable: true,
|
||||||
reqSecu: 3500,
|
reqSecu: 5000,
|
||||||
extraInfo: '[전투] 계략 시도 확률 +3%p',
|
extraInfo: '[전투] 계략 시도 확률 +3%p',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ const baseModule = createStatItemModule({
|
|||||||
slot: 'book',
|
slot: 'book',
|
||||||
statName: 'intelligence',
|
statName: 'intelligence',
|
||||||
statValue: STAT_VALUE,
|
statValue: STAT_VALUE,
|
||||||
cost: 9000,
|
cost: 21000,
|
||||||
buyable: true,
|
buyable: true,
|
||||||
reqSecu: 4000,
|
reqSecu: 6000,
|
||||||
extraInfo: '[전투] 계략 시도 확률 +3%p',
|
extraInfo: '[전투] 계략 시도 확률 +3%p',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { createStatItemModule } from './base.js';
|
import { createStatItemModule } from './base.js';
|
||||||
import type { ItemModule } from './types.js';
|
import type { ItemModule } from './types.js';
|
||||||
|
import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||||
|
import { che_반계발동, che_반계시도 } from '@sammo-ts/logic/war/triggers/che_반계.js';
|
||||||
|
|
||||||
export const itemModule: ItemModule = createStatItemModule({
|
const baseModule = createStatItemModule({
|
||||||
key: 'che_서적_12_산해경',
|
key: 'che_서적_12_산해경',
|
||||||
rawName: '산해경',
|
rawName: '산해경',
|
||||||
slot: 'book',
|
slot: 'book',
|
||||||
@@ -13,3 +15,9 @@ export const itemModule: ItemModule = createStatItemModule({
|
|||||||
unique: true,
|
unique: true,
|
||||||
extraInfo: '[전투] 상대의 계략을 10% 확률로 되돌림',
|
extraInfo: '[전투] 상대의 계략을 10% 확률로 되돌림',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const itemModule: ItemModule = {
|
||||||
|
...baseModule,
|
||||||
|
getBattlePhaseTriggerList: (context) =>
|
||||||
|
context.unit ? new WarTriggerCaller(new che_반계시도(context.unit, 0.1), new che_반계발동(context.unit)) : null,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,19 +1,3 @@
|
|||||||
import { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js';
|
import { createMedicalItem } from './createMedicalItem.js';
|
||||||
import { CheUisulCityHealTrigger } from '@sammo-ts/logic/triggers/generalTriggers/che_도시치료.js';
|
|
||||||
import type { ItemModule } from './types.js';
|
|
||||||
|
|
||||||
export const itemModule: ItemModule = {
|
export const itemModule = createMedicalItem('che_의술_상한잡병론', '상한잡병론');
|
||||||
key: 'che_의술_상한잡병론',
|
|
||||||
rawName: '상한잡병론',
|
|
||||||
name: '상한잡병론(의술)',
|
|
||||||
info: '[의술] 매달 도시의 부상병을 치료. 치료 효율 50% 향상',
|
|
||||||
slot: 'book',
|
|
||||||
cost: 500,
|
|
||||||
buyable: false,
|
|
||||||
consumable: false,
|
|
||||||
reqSecu: 0,
|
|
||||||
unique: false,
|
|
||||||
getPreTurnExecuteTriggerList: (context) => {
|
|
||||||
return new GeneralTriggerCaller(new CheUisulCityHealTrigger(context.general, 1.5));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,19 +1,3 @@
|
|||||||
import { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js';
|
import { createMedicalItem } from './createMedicalItem.js';
|
||||||
import { CheUisulCityHealTrigger } from '@sammo-ts/logic/triggers/generalTriggers/che_도시치료.js';
|
|
||||||
import type { ItemModule } from './types.js';
|
|
||||||
|
|
||||||
export const itemModule: ItemModule = {
|
export const itemModule = createMedicalItem('che_의술_정력견혈산', '정력견혈산');
|
||||||
key: 'che_의술_정력견혈산',
|
|
||||||
rawName: '정력견혈산',
|
|
||||||
name: '정력견혈산(의술)',
|
|
||||||
info: '[의술] 매달 도시의 부상병을 치료. 치료 효율 100% 향상',
|
|
||||||
slot: 'book',
|
|
||||||
cost: 500,
|
|
||||||
buyable: false,
|
|
||||||
consumable: false,
|
|
||||||
reqSecu: 0,
|
|
||||||
unique: false,
|
|
||||||
getPreTurnExecuteTriggerList: (context) => {
|
|
||||||
return new GeneralTriggerCaller(new CheUisulCityHealTrigger(context.general, 2));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,19 +1,3 @@
|
|||||||
import { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js';
|
import { createMedicalItem } from './createMedicalItem.js';
|
||||||
import { CheUisulCityHealTrigger } from '@sammo-ts/logic/triggers/generalTriggers/che_도시치료.js';
|
|
||||||
import type { ItemModule } from './types.js';
|
|
||||||
|
|
||||||
export const itemModule: ItemModule = {
|
export const itemModule = createMedicalItem('che_의술_청낭서', '청낭서');
|
||||||
key: 'che_의술_청낭서',
|
|
||||||
rawName: '청낭서',
|
|
||||||
name: '청낭서(의술)',
|
|
||||||
info: '[의술] 매달 도시의 부상병을 치료. 치료 효율 50% 향상',
|
|
||||||
slot: 'book',
|
|
||||||
cost: 500,
|
|
||||||
buyable: false,
|
|
||||||
consumable: false,
|
|
||||||
reqSecu: 0,
|
|
||||||
unique: false,
|
|
||||||
getPreTurnExecuteTriggerList: (context) => {
|
|
||||||
return new GeneralTriggerCaller(new CheUisulCityHealTrigger(context.general, 1.5));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,19 +1,3 @@
|
|||||||
import { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js';
|
import { createMedicalItem } from './createMedicalItem.js';
|
||||||
import { CheUisulCityHealTrigger } from '@sammo-ts/logic/triggers/generalTriggers/che_도시치료.js';
|
|
||||||
import type { ItemModule } from './types.js';
|
|
||||||
|
|
||||||
export const itemModule: ItemModule = {
|
export const itemModule = createMedicalItem('che_의술_태평청령', '태평청령');
|
||||||
key: 'che_의술_태평청령',
|
|
||||||
rawName: '태평청령',
|
|
||||||
name: '태평청령(의술)',
|
|
||||||
info: '[의술] 매달 도시의 부상병을 치료. 치료 효율 50% 향상',
|
|
||||||
slot: 'book',
|
|
||||||
cost: 500,
|
|
||||||
buyable: false,
|
|
||||||
consumable: false,
|
|
||||||
reqSecu: 0,
|
|
||||||
unique: false,
|
|
||||||
getPreTurnExecuteTriggerList: (context) => {
|
|
||||||
return new GeneralTriggerCaller(new CheUisulCityHealTrigger(context.general, 1.5));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const itemModule: ItemModule = {
|
|||||||
key: 'che_저격_매화수전',
|
key: 'che_저격_매화수전',
|
||||||
rawName: '매화수전',
|
rawName: '매화수전',
|
||||||
name: '매화수전(저격)',
|
name: '매화수전(저격)',
|
||||||
info: '[전투] 계략 시도 단계에서 저격 확률 50%',
|
info: '[전투] 새로운 상대와 전투 시 50% 확률로 저격 발동, 성공 시 사기+20',
|
||||||
slot: 'item',
|
slot: 'item',
|
||||||
cost: 200,
|
cost: 200,
|
||||||
buyable: false,
|
buyable: false,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const itemModule: ItemModule = {
|
|||||||
key: 'che_저격_비도',
|
key: 'che_저격_비도',
|
||||||
rawName: '비도',
|
rawName: '비도',
|
||||||
name: '비도(저격)',
|
name: '비도(저격)',
|
||||||
info: '[전투] 계략 시도 단계에서 저격 확률 50%',
|
info: '[전투] 새로운 상대와 전투 시 50% 확률로 저격 발동, 성공 시 사기+20',
|
||||||
slot: 'item',
|
slot: 'item',
|
||||||
cost: 200,
|
cost: 200,
|
||||||
buyable: false,
|
buyable: false,
|
||||||
|
|||||||
@@ -15,12 +15,7 @@ export const itemModule: ItemModule = {
|
|||||||
unique: false,
|
unique: false,
|
||||||
getWarPowerMultiplier: (_context, _unit, oppose) => {
|
getWarPowerMultiplier: (_context, _unit, oppose) => {
|
||||||
const opposeCrewType = oppose.getCrewType();
|
const opposeCrewType = oppose.getCrewType();
|
||||||
// In Sammo, region/city units usually have reqCities or reqRegions in crewType.
|
if (opposeCrewType.reqCities() || opposeCrewType.reqRegions()) {
|
||||||
// We'll check if those properties exist and have at least one element.
|
|
||||||
if (
|
|
||||||
(opposeCrewType.reqCities && opposeCrewType.reqCities.length > 0) ||
|
|
||||||
(opposeCrewType.reqRegions && opposeCrewType.reqRegions.length > 0)
|
|
||||||
) {
|
|
||||||
return [1.15, 0.85];
|
return [1.15, 0.85];
|
||||||
}
|
}
|
||||||
return [1, 1];
|
return [1, 1];
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js';
|
||||||
|
import { CheUisulCityHealTrigger } from '@sammo-ts/logic/triggers/generalTriggers/che_도시치료.js';
|
||||||
|
import { triggerModule as medicalWarTriggerModule } from '@sammo-ts/logic/war/triggers/che_의술.js';
|
||||||
|
import type { ItemModule } from './types.js';
|
||||||
|
|
||||||
|
const INFO =
|
||||||
|
'[군사] 매 턴마다 자신(100%)과 소속 도시 장수(적 포함 50%) 부상 회복<br>[전투] 페이즈마다 40% 확률로 치료 발동(아군 피해 30% 감소, 부상 회복)';
|
||||||
|
|
||||||
|
export const createMedicalItem = (key: string, rawName: string): ItemModule => ({
|
||||||
|
key,
|
||||||
|
rawName,
|
||||||
|
name: `${rawName}(의술)`,
|
||||||
|
info: INFO,
|
||||||
|
slot: 'item',
|
||||||
|
cost: 200,
|
||||||
|
buyable: false,
|
||||||
|
consumable: false,
|
||||||
|
reqSecu: 0,
|
||||||
|
unique: true,
|
||||||
|
getPreTurnExecuteTriggerList: (context) => new GeneralTriggerCaller(new CheUisulCityHealTrigger(context.general)),
|
||||||
|
getBattlePhaseTriggerList: (context) =>
|
||||||
|
context.unit ? medicalWarTriggerModule.createTriggerList(context.unit) : null,
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { TraitModule } from '@sammo-ts/logic/triggers/special/types.js';
|
||||||
|
import type { ItemModule } from './types.js';
|
||||||
|
|
||||||
|
export const createEventBattleTraitItemModule = (
|
||||||
|
key: string,
|
||||||
|
traitModule: TraitModule,
|
||||||
|
overrides: Partial<
|
||||||
|
Pick<ItemModule, 'rawName' | 'name' | 'info' | 'cost' | 'buyable' | 'consumable' | 'reqSecu'>
|
||||||
|
> = {}
|
||||||
|
): ItemModule => {
|
||||||
|
const itemModule: ItemModule = {
|
||||||
|
key,
|
||||||
|
rawName: overrides.rawName ?? '비급',
|
||||||
|
name: overrides.name ?? `비급(${traitModule.name})`,
|
||||||
|
info: overrides.info ?? traitModule.info,
|
||||||
|
slot: 'item',
|
||||||
|
cost: overrides.cost ?? 100,
|
||||||
|
buyable: overrides.buyable ?? true,
|
||||||
|
consumable: overrides.consumable ?? false,
|
||||||
|
reqSecu: overrides.reqSecu ?? 3000,
|
||||||
|
unique: false,
|
||||||
|
};
|
||||||
|
if (traitModule.getPreTurnExecuteTriggerList) {
|
||||||
|
itemModule.getPreTurnExecuteTriggerList = traitModule.getPreTurnExecuteTriggerList;
|
||||||
|
}
|
||||||
|
if (traitModule.onCalcDomestic) {
|
||||||
|
itemModule.onCalcDomestic = traitModule.onCalcDomestic;
|
||||||
|
}
|
||||||
|
if (traitModule.onCalcStat) {
|
||||||
|
itemModule.onCalcStat = traitModule.onCalcStat;
|
||||||
|
}
|
||||||
|
if (traitModule.onCalcOpposeStat) {
|
||||||
|
itemModule.onCalcOpposeStat = traitModule.onCalcOpposeStat;
|
||||||
|
}
|
||||||
|
if (traitModule.onCalcStrategic) {
|
||||||
|
itemModule.onCalcStrategic = traitModule.onCalcStrategic;
|
||||||
|
}
|
||||||
|
if (traitModule.onCalcNationalIncome) {
|
||||||
|
itemModule.onCalcNationalIncome = traitModule.onCalcNationalIncome;
|
||||||
|
}
|
||||||
|
if (traitModule.onArbitraryAction) {
|
||||||
|
itemModule.onArbitraryAction = traitModule.onArbitraryAction;
|
||||||
|
}
|
||||||
|
if (traitModule.getBattleInitTriggerList) {
|
||||||
|
itemModule.getBattleInitTriggerList = traitModule.getBattleInitTriggerList;
|
||||||
|
}
|
||||||
|
if (traitModule.getBattlePhaseTriggerList) {
|
||||||
|
itemModule.getBattlePhaseTriggerList = traitModule.getBattlePhaseTriggerList;
|
||||||
|
}
|
||||||
|
if (traitModule.getWarPowerMultiplier) {
|
||||||
|
itemModule.getWarPowerMultiplier =
|
||||||
|
traitModule.key === 'che_무쌍'
|
||||||
|
? (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 === traitModule.key
|
||||||
|
? [1, 1]
|
||||||
|
: traitModule.getWarPowerMultiplier!(context, unit, oppose);
|
||||||
|
}
|
||||||
|
: traitModule.getWarPowerMultiplier;
|
||||||
|
}
|
||||||
|
return itemModule;
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
|
||||||
|
import { consumeEquippedItemCharge, getEquippedItemInstance } from './inventory.js';
|
||||||
|
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||||
|
import { WarUnitCity, WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js';
|
||||||
|
import type { ItemModule } from './types.js';
|
||||||
|
|
||||||
|
const ITEM_KEY = 'event_충차';
|
||||||
|
|
||||||
|
class EventRamConsumptionTrigger extends BaseWarUnitTrigger {
|
||||||
|
constructor(unit: WarUnit) {
|
||||||
|
super(unit, TriggerPriority.Pre + 200, BaseWarUnitTrigger.TYPE_CONSUMABLE_ITEM);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected actionWar(
|
||||||
|
self: WarUnit,
|
||||||
|
oppose: WarUnit,
|
||||||
|
_selfEnv: Record<string, unknown>,
|
||||||
|
_opposeEnv: Record<string, unknown>
|
||||||
|
): boolean {
|
||||||
|
if (!(self instanceof WarUnitGeneral) || !(oppose instanceof WarUnitCity)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (self.hasActivatedSkillOnLog('충차공격') > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const general = self.getGeneral();
|
||||||
|
if (getEquippedItemInstance(general, 'item')?.itemKey !== ITEM_KEY) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.activateSkill('충차공격', '아이템사용');
|
||||||
|
self.getLogger().pushGeneralBattleDetailLog('<C>충차</>로 성벽을 공격합니다.');
|
||||||
|
consumeEquippedItemCharge(general, 'item', ITEM_KEY, 2);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const itemModule: ItemModule = {
|
||||||
|
key: ITEM_KEY,
|
||||||
|
rawName: '충차',
|
||||||
|
name: '충차',
|
||||||
|
info: '[전투] 성벽 공격 시 대미지 +50%, 2회용',
|
||||||
|
slot: 'item',
|
||||||
|
cost: 2000,
|
||||||
|
buyable: true,
|
||||||
|
consumable: true,
|
||||||
|
initialCharges: 2,
|
||||||
|
reqSecu: 3000,
|
||||||
|
unique: false,
|
||||||
|
getWarPowerMultiplier: (_context, _unit, oppose) => (oppose instanceof WarUnitCity ? [1.5, 1] : [1, 1]),
|
||||||
|
getBattlePhaseTriggerList: (context) =>
|
||||||
|
context.unit ? new WarTriggerCaller(new EventRamConsumptionTrigger(context.unit)) : null,
|
||||||
|
};
|
||||||
@@ -17,6 +17,7 @@ import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
|||||||
import type { WarUnit } from '@sammo-ts/logic/war/units.js';
|
import type { WarUnit } from '@sammo-ts/logic/war/units.js';
|
||||||
import type { ItemModule, ItemModuleExport } from './types.js';
|
import type { ItemModule, ItemModuleExport } from './types.js';
|
||||||
import { listEquippedItemKeys } from './utils.js';
|
import { listEquippedItemKeys } from './utils.js';
|
||||||
|
import { removeEquippedItem } from './inventory.js';
|
||||||
|
|
||||||
export const ITEM_KEYS = [
|
export const ITEM_KEYS = [
|
||||||
'che_간파_노군입산부',
|
'che_간파_노군입산부',
|
||||||
@@ -120,6 +121,7 @@ export const ITEM_KEYS = [
|
|||||||
'che_서적_15_손자병법',
|
'che_서적_15_손자병법',
|
||||||
'che_숙련_동작',
|
'che_숙련_동작',
|
||||||
'che_약탈_옥벽',
|
'che_약탈_옥벽',
|
||||||
|
'che_위압_조목삭',
|
||||||
'che_의술_상한잡병론',
|
'che_의술_상한잡병론',
|
||||||
'che_의술_정력견혈산',
|
'che_의술_정력견혈산',
|
||||||
'che_의술_청낭서',
|
'che_의술_청낭서',
|
||||||
@@ -142,6 +144,27 @@ export const ITEM_KEYS = [
|
|||||||
'che_훈련_단결도',
|
'che_훈련_단결도',
|
||||||
'che_훈련_철벽서',
|
'che_훈련_철벽서',
|
||||||
'che_훈련_청주',
|
'che_훈련_청주',
|
||||||
|
'event_전투특기_격노',
|
||||||
|
'event_전투특기_견고',
|
||||||
|
'event_전투특기_공성',
|
||||||
|
'event_전투특기_궁병',
|
||||||
|
'event_전투특기_귀병',
|
||||||
|
'event_전투특기_기병',
|
||||||
|
'event_전투특기_돌격',
|
||||||
|
'event_전투특기_무쌍',
|
||||||
|
'event_전투특기_반계',
|
||||||
|
'event_전투특기_보병',
|
||||||
|
'event_전투특기_신산',
|
||||||
|
'event_전투특기_신중',
|
||||||
|
'event_전투특기_위압',
|
||||||
|
'event_전투특기_의술',
|
||||||
|
'event_전투특기_저격',
|
||||||
|
'event_전투특기_집중',
|
||||||
|
'event_전투특기_징병',
|
||||||
|
'event_전투특기_척사',
|
||||||
|
'event_전투특기_필살',
|
||||||
|
'event_전투특기_환술',
|
||||||
|
'event_충차',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ItemKey = (typeof ITEM_KEYS)[number];
|
export type ItemKey = (typeof ITEM_KEYS)[number];
|
||||||
@@ -250,6 +273,21 @@ const defaultImporters: Record<ItemKey, ItemImporter> = {
|
|||||||
che_서적_15_손자병법: async () => import('./che_서적_15_손자병법.js'),
|
che_서적_15_손자병법: async () => import('./che_서적_15_손자병법.js'),
|
||||||
che_숙련_동작: async () => import('./che_숙련_동작.js'),
|
che_숙련_동작: async () => import('./che_숙련_동작.js'),
|
||||||
che_약탈_옥벽: async () => import('./che_약탈_옥벽.js'),
|
che_약탈_옥벽: async () => import('./che_약탈_옥벽.js'),
|
||||||
|
che_위압_조목삭: async () => {
|
||||||
|
const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([
|
||||||
|
import('../triggers/special/war/che_위압.js'),
|
||||||
|
import('./eventBattleTrait.js'),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
itemModule: createEventBattleTraitItemModule('che_위압_조목삭', traitModule, {
|
||||||
|
rawName: '조목삭',
|
||||||
|
name: '조목삭(위압)',
|
||||||
|
cost: 200,
|
||||||
|
buyable: false,
|
||||||
|
reqSecu: 0,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
che_의술_상한잡병론: async () => import('./che_의술_상한잡병론.js'),
|
che_의술_상한잡병론: async () => import('./che_의술_상한잡병론.js'),
|
||||||
che_의술_정력견혈산: async () => import('./che_의술_정력견혈산.js'),
|
che_의술_정력견혈산: async () => import('./che_의술_정력견혈산.js'),
|
||||||
che_의술_청낭서: async () => import('./che_의술_청낭서.js'),
|
che_의술_청낭서: async () => import('./che_의술_청낭서.js'),
|
||||||
@@ -272,6 +310,147 @@ const defaultImporters: Record<ItemKey, ItemImporter> = {
|
|||||||
che_훈련_단결도: async () => import('./che_훈련_단결도.js'),
|
che_훈련_단결도: async () => import('./che_훈련_단결도.js'),
|
||||||
che_훈련_철벽서: async () => import('./che_훈련_철벽서.js'),
|
che_훈련_철벽서: async () => import('./che_훈련_철벽서.js'),
|
||||||
che_훈련_청주: async () => import('./che_훈련_청주.js'),
|
che_훈련_청주: async () => import('./che_훈련_청주.js'),
|
||||||
|
event_전투특기_격노: async () => {
|
||||||
|
const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([
|
||||||
|
import('../triggers/special/war/che_격노.js'),
|
||||||
|
import('./eventBattleTrait.js'),
|
||||||
|
]);
|
||||||
|
return { itemModule: createEventBattleTraitItemModule('event_전투특기_격노', traitModule) };
|
||||||
|
},
|
||||||
|
event_전투특기_견고: async () => {
|
||||||
|
const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([
|
||||||
|
import('../triggers/special/war/che_견고.js'),
|
||||||
|
import('./eventBattleTrait.js'),
|
||||||
|
]);
|
||||||
|
return { itemModule: createEventBattleTraitItemModule('event_전투특기_견고', traitModule) };
|
||||||
|
},
|
||||||
|
event_전투특기_공성: async () => {
|
||||||
|
const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([
|
||||||
|
import('../triggers/special/war/che_공성.js'),
|
||||||
|
import('./eventBattleTrait.js'),
|
||||||
|
]);
|
||||||
|
return { itemModule: createEventBattleTraitItemModule('event_전투특기_공성', traitModule) };
|
||||||
|
},
|
||||||
|
event_전투특기_궁병: async () => {
|
||||||
|
const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([
|
||||||
|
import('../triggers/special/war/che_궁병.js'),
|
||||||
|
import('./eventBattleTrait.js'),
|
||||||
|
]);
|
||||||
|
return { itemModule: createEventBattleTraitItemModule('event_전투특기_궁병', traitModule) };
|
||||||
|
},
|
||||||
|
event_전투특기_귀병: async () => {
|
||||||
|
const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([
|
||||||
|
import('../triggers/special/war/che_귀병.js'),
|
||||||
|
import('./eventBattleTrait.js'),
|
||||||
|
]);
|
||||||
|
return { itemModule: createEventBattleTraitItemModule('event_전투특기_귀병', traitModule) };
|
||||||
|
},
|
||||||
|
event_전투특기_기병: async () => {
|
||||||
|
const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([
|
||||||
|
import('../triggers/special/war/che_기병.js'),
|
||||||
|
import('./eventBattleTrait.js'),
|
||||||
|
]);
|
||||||
|
return { itemModule: createEventBattleTraitItemModule('event_전투특기_기병', traitModule) };
|
||||||
|
},
|
||||||
|
event_전투특기_돌격: async () => {
|
||||||
|
const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([
|
||||||
|
import('../triggers/special/war/che_돌격.js'),
|
||||||
|
import('./eventBattleTrait.js'),
|
||||||
|
]);
|
||||||
|
return { itemModule: createEventBattleTraitItemModule('event_전투특기_돌격', traitModule) };
|
||||||
|
},
|
||||||
|
event_전투특기_무쌍: async () => {
|
||||||
|
const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([
|
||||||
|
import('../triggers/special/war/che_무쌍.js'),
|
||||||
|
import('./eventBattleTrait.js'),
|
||||||
|
]);
|
||||||
|
return { itemModule: createEventBattleTraitItemModule('event_전투특기_무쌍', traitModule) };
|
||||||
|
},
|
||||||
|
event_전투특기_반계: async () => {
|
||||||
|
const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([
|
||||||
|
import('../triggers/special/war/che_반계.js'),
|
||||||
|
import('./eventBattleTrait.js'),
|
||||||
|
]);
|
||||||
|
return { itemModule: createEventBattleTraitItemModule('event_전투특기_반계', traitModule) };
|
||||||
|
},
|
||||||
|
event_전투특기_보병: async () => {
|
||||||
|
const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([
|
||||||
|
import('../triggers/special/war/che_보병.js'),
|
||||||
|
import('./eventBattleTrait.js'),
|
||||||
|
]);
|
||||||
|
return { itemModule: createEventBattleTraitItemModule('event_전투특기_보병', traitModule) };
|
||||||
|
},
|
||||||
|
event_전투특기_신산: async () => {
|
||||||
|
const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([
|
||||||
|
import('../triggers/special/war/che_신산.js'),
|
||||||
|
import('./eventBattleTrait.js'),
|
||||||
|
]);
|
||||||
|
return { itemModule: createEventBattleTraitItemModule('event_전투특기_신산', traitModule) };
|
||||||
|
},
|
||||||
|
event_전투특기_신중: async () => {
|
||||||
|
const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([
|
||||||
|
import('../triggers/special/war/che_신중.js'),
|
||||||
|
import('./eventBattleTrait.js'),
|
||||||
|
]);
|
||||||
|
return { itemModule: createEventBattleTraitItemModule('event_전투특기_신중', traitModule) };
|
||||||
|
},
|
||||||
|
event_전투특기_위압: async () => {
|
||||||
|
const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([
|
||||||
|
import('../triggers/special/war/che_위압.js'),
|
||||||
|
import('./eventBattleTrait.js'),
|
||||||
|
]);
|
||||||
|
return { itemModule: createEventBattleTraitItemModule('event_전투특기_위압', traitModule) };
|
||||||
|
},
|
||||||
|
event_전투특기_의술: async () => {
|
||||||
|
const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([
|
||||||
|
import('../triggers/special/war/che_의술.js'),
|
||||||
|
import('./eventBattleTrait.js'),
|
||||||
|
]);
|
||||||
|
return { itemModule: createEventBattleTraitItemModule('event_전투특기_의술', traitModule) };
|
||||||
|
},
|
||||||
|
event_전투특기_저격: async () => {
|
||||||
|
const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([
|
||||||
|
import('../triggers/special/war/che_저격.js'),
|
||||||
|
import('./eventBattleTrait.js'),
|
||||||
|
]);
|
||||||
|
return { itemModule: createEventBattleTraitItemModule('event_전투특기_저격', traitModule) };
|
||||||
|
},
|
||||||
|
event_전투특기_집중: async () => {
|
||||||
|
const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([
|
||||||
|
import('../triggers/special/war/che_집중.js'),
|
||||||
|
import('./eventBattleTrait.js'),
|
||||||
|
]);
|
||||||
|
return { itemModule: createEventBattleTraitItemModule('event_전투특기_집중', traitModule) };
|
||||||
|
},
|
||||||
|
event_전투특기_징병: async () => {
|
||||||
|
const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([
|
||||||
|
import('../triggers/special/war/che_징병.js'),
|
||||||
|
import('./eventBattleTrait.js'),
|
||||||
|
]);
|
||||||
|
return { itemModule: createEventBattleTraitItemModule('event_전투특기_징병', traitModule) };
|
||||||
|
},
|
||||||
|
event_전투특기_척사: async () => {
|
||||||
|
const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([
|
||||||
|
import('../triggers/special/war/che_척사.js'),
|
||||||
|
import('./eventBattleTrait.js'),
|
||||||
|
]);
|
||||||
|
return { itemModule: createEventBattleTraitItemModule('event_전투특기_척사', traitModule) };
|
||||||
|
},
|
||||||
|
event_전투특기_필살: async () => {
|
||||||
|
const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([
|
||||||
|
import('../triggers/special/war/che_필살.js'),
|
||||||
|
import('./eventBattleTrait.js'),
|
||||||
|
]);
|
||||||
|
return { itemModule: createEventBattleTraitItemModule('event_전투특기_필살', traitModule) };
|
||||||
|
},
|
||||||
|
event_전투특기_환술: async () => {
|
||||||
|
const [{ traitModule }, { createEventBattleTraitItemModule }] = await Promise.all([
|
||||||
|
import('../triggers/special/war/che_환술.js'),
|
||||||
|
import('./eventBattleTrait.js'),
|
||||||
|
]);
|
||||||
|
return { itemModule: createEventBattleTraitItemModule('event_전투특기_환술', traitModule) };
|
||||||
|
},
|
||||||
|
event_충차: async () => import('./event_충차.js'),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isItemKey = (value: string): value is ItemKey => ITEM_KEYS.includes(value as ItemKey);
|
export const isItemKey = (value: string): value is ItemKey => ITEM_KEYS.includes(value as ItemKey);
|
||||||
@@ -294,7 +473,13 @@ export class ItemLoader {
|
|||||||
if (!('itemModule' in module)) {
|
if (!('itemModule' in module)) {
|
||||||
throw new Error(`Missing itemModule for item: ${key}`);
|
throw new Error(`Missing itemModule for item: ${key}`);
|
||||||
}
|
}
|
||||||
const resolved = module.itemModule;
|
const resolved: ItemModule = {
|
||||||
|
...module.itemModule,
|
||||||
|
// Legacy defines the finite unique pool as every non-buyable
|
||||||
|
// scenario item; keep that invariant authoritative instead of
|
||||||
|
// relying on duplicated per-module flags.
|
||||||
|
unique: !module.itemModule.buyable,
|
||||||
|
};
|
||||||
if (resolved.key !== key) {
|
if (resolved.key !== key) {
|
||||||
throw new Error(`Item key mismatch: expected ${key}, got ${resolved.key}`);
|
throw new Error(`Item key mismatch: expected ${key}, got ${resolved.key}`);
|
||||||
}
|
}
|
||||||
@@ -455,11 +640,30 @@ class ItemGeneralActionRouter<
|
|||||||
let current = aux ?? null;
|
let current = aux ?? null;
|
||||||
for (const module of this.resolveModules(context)) {
|
for (const module of this.resolveModules(context)) {
|
||||||
if (!module.onArbitraryAction) {
|
if (!module.onArbitraryAction) {
|
||||||
continue;
|
// Declarative consumption rules are evaluated below even when
|
||||||
|
// the item has no custom arbitrary-action hook.
|
||||||
|
} else {
|
||||||
|
const result = module.onArbitraryAction(context, actionType, phase, current);
|
||||||
|
if (result !== undefined) {
|
||||||
|
current = result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const result = module.onArbitraryAction(context, actionType, phase, current);
|
|
||||||
if (result !== undefined) {
|
const shouldConsume = module.consumeOn?.some(
|
||||||
current = result;
|
(rule) =>
|
||||||
|
rule.actionType === actionType &&
|
||||||
|
(rule.phase === undefined || rule.phase === phase) &&
|
||||||
|
(rule.command === undefined || current?.['command'] === rule.command) &&
|
||||||
|
(!rule.successOnly || current?.['success'] === true)
|
||||||
|
);
|
||||||
|
if (shouldConsume) {
|
||||||
|
const removed = removeEquippedItem(context.general, module.slot);
|
||||||
|
if (removed?.itemKey === module.key) {
|
||||||
|
const consumedItems = Array.isArray(current?.['consumedItems'])
|
||||||
|
? (current['consumedItems'] as unknown[])
|
||||||
|
: [];
|
||||||
|
current = { ...(current ?? {}), consumedItems: [...consumedItems, module.key] };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return current;
|
return current;
|
||||||
@@ -581,6 +785,7 @@ export {
|
|||||||
getEquippedItemInstance,
|
getEquippedItemInstance,
|
||||||
parseItemInventory,
|
parseItemInventory,
|
||||||
projectItemSlots,
|
projectItemSlots,
|
||||||
|
readItemInventory,
|
||||||
readItemInventoryFromMeta,
|
readItemInventoryFromMeta,
|
||||||
removeEquippedItem,
|
removeEquippedItem,
|
||||||
serializeItemInventory,
|
serializeItemInventory,
|
||||||
|
|||||||
@@ -154,6 +154,10 @@ export const ensureItemInventory = <TriggerState extends GeneralTriggerState>(
|
|||||||
return general.itemInventory;
|
return general.itemInventory;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const readItemInventory = <TriggerState extends GeneralTriggerState>(
|
||||||
|
general: General<TriggerState>
|
||||||
|
): GeneralItemInventory => general.itemInventory ?? createItemInventoryFromSlots(general.role.items);
|
||||||
|
|
||||||
export const projectItemSlots = (inventory: GeneralItemInventory): GeneralItemSlots => {
|
export const projectItemSlots = (inventory: GeneralItemInventory): GeneralItemSlots => {
|
||||||
const slots: GeneralItemSlots = { horse: null, weapon: null, book: null, item: null };
|
const slots: GeneralItemSlots = { horse: null, weapon: null, book: null, item: null };
|
||||||
for (const slot of ITEM_SLOTS) {
|
for (const slot of ITEM_SLOTS) {
|
||||||
@@ -167,7 +171,7 @@ export const getEquippedItemInstance = <TriggerState extends GeneralTriggerState
|
|||||||
general: General<TriggerState>,
|
general: General<TriggerState>,
|
||||||
slot: GeneralItemSlot
|
slot: GeneralItemSlot
|
||||||
): GeneralItemInstance | null => {
|
): GeneralItemInstance | null => {
|
||||||
const inventory = ensureItemInventory(general);
|
const inventory = readItemInventory(general);
|
||||||
const instanceId = inventory.equipped[slot];
|
const instanceId = inventory.equipped[slot];
|
||||||
return instanceId ? (inventory.instances[instanceId] ?? null) : null;
|
return instanceId ? (inventory.instances[instanceId] ?? null) : null;
|
||||||
};
|
};
|
||||||
@@ -220,7 +224,9 @@ export const consumeEquippedItemCharge = <TriggerState extends GeneralTriggerSta
|
|||||||
itemKey: string,
|
itemKey: string,
|
||||||
fallbackCharges = 1
|
fallbackCharges = 1
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const instance = getEquippedItemInstance(general, slot);
|
const inventory = ensureItemInventory(general);
|
||||||
|
const instanceId = inventory.equipped[slot];
|
||||||
|
const instance = instanceId ? inventory.instances[instanceId] : undefined;
|
||||||
if (!instance || instance.itemKey !== itemKey) {
|
if (!instance || instance.itemKey !== itemKey) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ import type { WarUnit } from '@sammo-ts/logic/war/units.js';
|
|||||||
|
|
||||||
export type ItemSlot = 'horse' | 'weapon' | 'book' | 'item';
|
export type ItemSlot = 'horse' | 'weapon' | 'book' | 'item';
|
||||||
|
|
||||||
|
export interface ItemActionConsumptionRule {
|
||||||
|
actionType: TriggerActionType;
|
||||||
|
phase?: TriggerActionPhase | null;
|
||||||
|
command?: string;
|
||||||
|
successOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ItemModule<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
export interface ItemModule<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -27,6 +34,7 @@ export interface ItemModule<TriggerState extends GeneralTriggerState = GeneralTr
|
|||||||
buyable: boolean;
|
buyable: boolean;
|
||||||
consumable: boolean;
|
consumable: boolean;
|
||||||
initialCharges?: number;
|
initialCharges?: number;
|
||||||
|
consumeOn?: ItemActionConsumptionRule[];
|
||||||
reqSecu: number;
|
reqSecu: number;
|
||||||
unique: boolean;
|
unique: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import type { ScenarioConfig } from '@sammo-ts/logic/scenario/types.js';
|
import type { ScenarioConfig } from '@sammo-ts/logic/scenario/types.js';
|
||||||
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import type { ItemModule } from './types.js';
|
import type { ItemModule } from './types.js';
|
||||||
import { consumeEquippedItemCharge, ensureItemInventory, getEquippedItemInstance } from './inventory.js';
|
import {
|
||||||
|
consumeEquippedItemCharge,
|
||||||
|
ensureItemInventory,
|
||||||
|
getEquippedItemInstance,
|
||||||
|
readItemInventory,
|
||||||
|
} from './inventory.js';
|
||||||
|
|
||||||
const toBoolean = (value: unknown): boolean => {
|
const toBoolean = (value: unknown): boolean => {
|
||||||
if (typeof value === 'boolean') {
|
if (typeof value === 'boolean') {
|
||||||
@@ -27,7 +32,7 @@ export const isInventoryEnabled = (config: ScenarioConfig): boolean => {
|
|||||||
export const listEquippedItemKeys = <TriggerState extends GeneralTriggerState>(
|
export const listEquippedItemKeys = <TriggerState extends GeneralTriggerState>(
|
||||||
general: General<TriggerState>
|
general: General<TriggerState>
|
||||||
): string[] => {
|
): string[] => {
|
||||||
const inventory = ensureItemInventory(general);
|
const inventory = readItemInventory(general);
|
||||||
const items = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => {
|
const items = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => {
|
||||||
const instanceId = inventory.equipped[slot];
|
const instanceId = inventory.equipped[slot];
|
||||||
return instanceId ? (inventory.instances[instanceId]?.itemKey ?? null) : null;
|
return instanceId ? (inventory.instances[instanceId]?.itemKey ?? null) : null;
|
||||||
@@ -58,6 +63,7 @@ export const setItemRemain = <TriggerState extends GeneralTriggerState>(
|
|||||||
itemKey: string,
|
itemKey: string,
|
||||||
remain: number | null
|
remain: number | null
|
||||||
): void => {
|
): void => {
|
||||||
|
ensureItemInventory(general);
|
||||||
const instance = getEquippedItemInstance(general, 'item');
|
const instance = getEquippedItemInstance(general, 'item');
|
||||||
if (!instance || instance.itemKey !== itemKey) {
|
if (!instance || instance.itemKey !== itemKey) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type TriggerActionType = '장비매매';
|
export type TriggerActionType = '장비매매' | 'GeneralCommand';
|
||||||
|
|
||||||
export type TriggerActionPhase = '판매' | '구매';
|
export type TriggerActionPhase = '판매' | '구매';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { RandUtil } from '@sammo-ts/common';
|
import type { RandUtil } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
import type { General } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import { TriggerCaller, type Trigger } from '@sammo-ts/logic/triggers/core.js';
|
import { TriggerCaller, type Trigger } from '@sammo-ts/logic/triggers/core.js';
|
||||||
import type { WarUnit } from './units.js';
|
import type { WarUnit } from './units.js';
|
||||||
|
import { removeEquippedItem } from '@sammo-ts/logic/items/inventory.js';
|
||||||
|
|
||||||
export interface WarTriggerContext {
|
export interface WarTriggerContext {
|
||||||
rng: RandUtil;
|
rng: RandUtil;
|
||||||
@@ -87,11 +89,28 @@ export abstract class BaseWarUnitTrigger implements WarTrigger {
|
|||||||
opposeEnv: Record<string, unknown>
|
opposeEnv: Record<string, unknown>
|
||||||
): boolean;
|
): boolean;
|
||||||
|
|
||||||
// 아이템 소모 트리거는 아직 미구현. 필요 시 raiseType을 활용해 확장 가능하다.
|
|
||||||
public processConsumableItem(): boolean {
|
public processConsumableItem(): boolean {
|
||||||
if (!(this.raiseType & BaseWarUnitTrigger.TYPE_ITEM)) {
|
if (!(this.raiseType & BaseWarUnitTrigger.TYPE_ITEM)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return false;
|
if (this.unit.hasActivatedSkill('아이템사용')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.unit.activateSkill('아이템사용');
|
||||||
|
if (this.raiseType !== BaseWarUnitTrigger.TYPE_CONSUMABLE_ITEM) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.unit.hasActivatedSkill('아이템소모')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const unit = this.unit as WarUnit & {
|
||||||
|
getGeneral?: () => General;
|
||||||
|
};
|
||||||
|
const general = unit.getGeneral?.();
|
||||||
|
if (!general) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.unit.activateSkill('아이템소모');
|
||||||
|
return removeEquippedItem(general, 'item') !== null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
|
||||||
|
import { BaseWarUnitTrigger } from '@sammo-ts/logic/war/triggers.js';
|
||||||
|
import type { WarUnit } from '@sammo-ts/logic/war/units.js';
|
||||||
|
|
||||||
|
export class 전투력보정 extends BaseWarUnitTrigger {
|
||||||
|
constructor(
|
||||||
|
unit: WarUnit,
|
||||||
|
private readonly attackMultiplier: number,
|
||||||
|
private readonly defenceMultiplier = 1
|
||||||
|
) {
|
||||||
|
super(unit, TriggerPriority.Begin + 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected actionWar(
|
||||||
|
self: WarUnit,
|
||||||
|
oppose: WarUnit,
|
||||||
|
_selfEnv: Record<string, unknown>,
|
||||||
|
_opposeEnv: Record<string, unknown>
|
||||||
|
): boolean {
|
||||||
|
self.multiplyWarPowerMultiply(this.attackMultiplier);
|
||||||
|
oppose.multiplyWarPowerMultiply(this.defenceMultiplier);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,14 +15,7 @@ import { getTechAbility, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
|
|||||||
import type { WarActionPipeline, WarActionContext } from '../actions.js';
|
import type { WarActionPipeline, WarActionContext } from '../actions.js';
|
||||||
import type { WarEngineConfig } from '../types.js';
|
import type { WarEngineConfig } from '../types.js';
|
||||||
import type { WarCrewType } from '../crewType.js';
|
import type { WarCrewType } from '../crewType.js';
|
||||||
import {
|
import { clamp, clampMin, getMetaNumber, getMetaString, increaseMetaNumber, round } from '../utils.js';
|
||||||
clamp,
|
|
||||||
clampMin,
|
|
||||||
getMetaNumber,
|
|
||||||
getMetaString,
|
|
||||||
increaseMetaNumber,
|
|
||||||
round,
|
|
||||||
} from '../utils.js';
|
|
||||||
import { WAR_CRITICAL_RANGE, WarUnit, resolveNationTech } from './base.js';
|
import { WAR_CRITICAL_RANGE, WarUnit, resolveNationTech } from './base.js';
|
||||||
type CityUnit = WarUnit & { getCityId: () => number };
|
type CityUnit = WarUnit & { getCityId: () => number };
|
||||||
|
|
||||||
@@ -150,24 +143,47 @@ export class WarUnitGeneral<
|
|||||||
public getComputedStat(
|
public getComputedStat(
|
||||||
statName: keyof StatBlock,
|
statName: keyof StatBlock,
|
||||||
base: number,
|
base: number,
|
||||||
options: { withInjury?: boolean; withStatAdjust?: boolean; withActions?: boolean } = {}
|
options: {
|
||||||
|
withInjury?: boolean;
|
||||||
|
withStatAdjust?: boolean;
|
||||||
|
withActions?: boolean;
|
||||||
|
truncate?: boolean;
|
||||||
|
} = {}
|
||||||
): number {
|
): number {
|
||||||
const withInjury = options.withInjury ?? true;
|
const withInjury = options.withInjury ?? true;
|
||||||
const withStatAdjust = options.withStatAdjust ?? true;
|
const withStatAdjust = options.withStatAdjust ?? true;
|
||||||
const withActions = options.withActions ?? true;
|
const withActions = options.withActions ?? true;
|
||||||
|
const truncate = options.truncate ?? true;
|
||||||
const injuryRatio = withInjury ? (100 - this.general.injury) / 100 : 1;
|
const injuryRatio = withInjury ? (100 - this.general.injury) / 100 : 1;
|
||||||
let value = base * injuryRatio;
|
let value = base * injuryRatio;
|
||||||
if (withStatAdjust && statName === 'strength') {
|
if (withStatAdjust && statName === 'strength') {
|
||||||
value += round((this.general.stats.intelligence * injuryRatio) / 4);
|
value += round(
|
||||||
|
this.getComputedStat('intelligence', this.general.stats.intelligence, {
|
||||||
|
withInjury,
|
||||||
|
withStatAdjust: false,
|
||||||
|
withActions,
|
||||||
|
truncate: false,
|
||||||
|
}) / 4
|
||||||
|
);
|
||||||
} else if (withStatAdjust && statName === 'intelligence') {
|
} else if (withStatAdjust && statName === 'intelligence') {
|
||||||
value += round((this.general.stats.strength * injuryRatio) / 4);
|
value += round(
|
||||||
|
this.getComputedStat('strength', this.general.stats.strength, {
|
||||||
|
withInjury,
|
||||||
|
withStatAdjust: false,
|
||||||
|
withActions,
|
||||||
|
truncate: false,
|
||||||
|
}) / 4
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const maxGeneralStat = this.config.maxGeneralStat ?? 255;
|
const maxGeneralStat = this.config.maxGeneralStat ?? 255;
|
||||||
value = clamp(value, 0, maxGeneralStat);
|
value = clamp(value, 0, maxGeneralStat);
|
||||||
if (withActions) {
|
if (withActions) {
|
||||||
value = this.actionPipeline.onCalcStat(this.getActionContext(), statName, value);
|
value = this.actionPipeline.onCalcStat(this.getActionContext(), statName, value);
|
||||||
}
|
}
|
||||||
return clamp(value, 0, maxGeneralStat);
|
// Legacy General::getStatValue() applies every action and then returns
|
||||||
|
// Util::toInt(), which truncates toward zero on every stat read.
|
||||||
|
const clamped = clamp(value, 0, maxGeneralStat);
|
||||||
|
return truncate ? Math.trunc(clamped) : clamped;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveMainStat(armType: number, withInjury = true): number {
|
private resolveMainStat(armType: number, withInjury = true): number {
|
||||||
@@ -345,7 +361,8 @@ export class WarUnitGeneral<
|
|||||||
: crewType.armType;
|
: crewType.armType;
|
||||||
const key = `${META_DEX_PREFIX}${armType}`;
|
const key = `${META_DEX_PREFIX}${armType}`;
|
||||||
const base = getMetaNumber(this.general.meta, key);
|
const base = getMetaNumber(this.general.meta, key);
|
||||||
this.general.meta[key] = round(base + exp);
|
const adjustedExp = this.actionPipeline.onCalcStat(this.getActionContext(), 'addDex', exp, { armType });
|
||||||
|
this.general.meta[key] = base + adjustedExp;
|
||||||
}
|
}
|
||||||
|
|
||||||
public calcRiceConsumption(damage: number): number {
|
public calcRiceConsumption(damage: number): number {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
projectItemSlots,
|
projectItemSlots,
|
||||||
serializeItemInventory,
|
serializeItemInventory,
|
||||||
} from '../src/items/inventory.js';
|
} from '../src/items/inventory.js';
|
||||||
|
import { listEquippedItemKeys } from '../src/items/utils.js';
|
||||||
|
|
||||||
const makeGeneral = (): General => ({
|
const makeGeneral = (): General => ({
|
||||||
id: 7,
|
id: 7,
|
||||||
@@ -58,6 +59,16 @@ describe('GeneralItemInventory', () => {
|
|||||||
expect(Object.keys(inventory.instances)).toHaveLength(3);
|
expect(Object.keys(inventory.instances)).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reads legacy slots without mutating a frozen world snapshot', () => {
|
||||||
|
const general = makeGeneral();
|
||||||
|
general.role.items.item = 'che_치료_환약';
|
||||||
|
const frozen = Object.freeze(general);
|
||||||
|
|
||||||
|
expect(listEquippedItemKeys(frozen)).toEqual(['che_치료_환약']);
|
||||||
|
expect(getEquippedItemInstance(frozen, 'item')?.itemKey).toBe('che_치료_환약');
|
||||||
|
expect(frozen.itemInventory).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps consumable charges in the equipped item instance', () => {
|
it('keeps consumable charges in the equipped item instance', () => {
|
||||||
const general = makeGeneral();
|
const general = makeGeneral();
|
||||||
equipNewItem(general, 'item', 'che_치료_환약', { charges: 3 });
|
equipNewItem(general, 'item', 'che_치료_환약', { charges: 3 });
|
||||||
|
|||||||
@@ -14,7 +14,15 @@ import { commandSpec as spySpec } from '../../src/actions/turn/general/che_첩
|
|||||||
import { commandSpec as destroySpec } from '../../src/actions/turn/general/che_파괴.js';
|
import { commandSpec as destroySpec } from '../../src/actions/turn/general/che_파괴.js';
|
||||||
import { commandSpec as agitateSpec } from '../../src/actions/turn/general/che_선동.js';
|
import { commandSpec as agitateSpec } from '../../src/actions/turn/general/che_선동.js';
|
||||||
import { commandSpec as seizeSpec } from '../../src/actions/turn/general/che_탈취.js';
|
import { commandSpec as seizeSpec } from '../../src/actions/turn/general/che_탈취.js';
|
||||||
|
import { commandSpec as fireSpec } from '../../src/actions/turn/general/che_화계.js';
|
||||||
import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js';
|
||||||
|
import {
|
||||||
|
createItemActionModules,
|
||||||
|
createItemModuleRegistry,
|
||||||
|
equipNewItem,
|
||||||
|
getEquippedItemInstance,
|
||||||
|
loadItemModules,
|
||||||
|
} from '../../src/items/index.js';
|
||||||
|
|
||||||
describe('General Commands New Scenario', () => {
|
describe('General Commands New Scenario', () => {
|
||||||
// 1. Setup Environment
|
// 1. Setup Environment
|
||||||
@@ -357,6 +365,7 @@ describe('General Commands New Scenario', () => {
|
|||||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||||
meta: {},
|
meta: {},
|
||||||
});
|
});
|
||||||
|
equipNewItem(gen1, 'item', 'che_계략_이추');
|
||||||
|
|
||||||
const gen2: General = { id: 2, name: 'G2', nationId: 2, cityId: 2, troopId: 0, npcState: 0, ticket: 0 } as any;
|
const gen2: General = { id: 2, name: 'G2', nationId: 2, cityId: 2, troopId: 0, npcState: 0, ticket: 0 } as any;
|
||||||
Object.assign(gen2, {
|
Object.assign(gen2, {
|
||||||
@@ -392,6 +401,16 @@ describe('General Commands New Scenario', () => {
|
|||||||
|
|
||||||
const world = new InMemoryWorld(snapshot);
|
const world = new InMemoryWorld(snapshot);
|
||||||
const runner = new TestGameRunner(world, 200, 1);
|
const runner = new TestGameRunner(world, 200, 1);
|
||||||
|
const itemGeneralModules = createItemActionModules(
|
||||||
|
createItemModuleRegistry(await loadItemModules(['che_계략_이추', 'che_계략_향낭']))
|
||||||
|
).general;
|
||||||
|
const reEquipStrategyItem = (itemKey: 'che_계략_이추' | 'che_계략_향낭'): void => {
|
||||||
|
const nextGeneral = structuredClone(world.getGeneral(1)!);
|
||||||
|
equipNewItem(nextGeneral, 'item', itemKey);
|
||||||
|
world.snapshot.generals = world.snapshot.generals.map((general) =>
|
||||||
|
general.id === nextGeneral.id ? nextGeneral : general
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 1. Employ (G1 -> G2)
|
// 1. Employ (G1 -> G2)
|
||||||
const employDef = employSpec.createDefinition(systemEnv);
|
const employDef = employSpec.createDefinition(systemEnv);
|
||||||
@@ -424,7 +443,8 @@ describe('General Commands New Scenario', () => {
|
|||||||
expect(spyInfo['2']).toBe(3); // City 2 spied level 3
|
expect(spyInfo['2']).toBe(3); // City 2 spied level 3
|
||||||
|
|
||||||
// 3. Destroy (G1 -> C2)
|
// 3. Destroy (G1 -> C2)
|
||||||
const destroyDef = destroySpec.createDefinition(systemEnv);
|
const strategyEnv = { ...systemEnv, generalActionModules: itemGeneralModules };
|
||||||
|
const destroyDef = destroySpec.createDefinition(strategyEnv);
|
||||||
await runner.runTurn([
|
await runner.runTurn([
|
||||||
{
|
{
|
||||||
generalId: 1,
|
generalId: 1,
|
||||||
@@ -438,9 +458,42 @@ describe('General Commands New Scenario', () => {
|
|||||||
const c2_after_destroy = world.getCity(2)!;
|
const c2_after_destroy = world.getCity(2)!;
|
||||||
expect(c2_after_destroy.defence).toBeLessThan(1000);
|
expect(c2_after_destroy.defence).toBeLessThan(1000);
|
||||||
expect(c2_after_destroy.state).toBe(32);
|
expect(c2_after_destroy.state).toBe(32);
|
||||||
|
expect(getEquippedItemInstance(world.getGeneral(1)!, 'item')).toBeNull();
|
||||||
|
|
||||||
// 4. Agitate (G1 -> C2)
|
// 4. Fire attack consumes the successful one-use strategy item.
|
||||||
const agitateDef = agitateSpec.createDefinition(systemEnv);
|
reEquipStrategyItem('che_계략_향낭');
|
||||||
|
const fireDef = fireSpec.createDefinition(strategyEnv);
|
||||||
|
await runner.runTurn([
|
||||||
|
{
|
||||||
|
generalId: 1,
|
||||||
|
commandKey: 'che_화계',
|
||||||
|
resolver: fireDef,
|
||||||
|
args: { destCityId: 2 },
|
||||||
|
context: {
|
||||||
|
destCity: city2,
|
||||||
|
destNation: nation2,
|
||||||
|
destGenerals: [gen2],
|
||||||
|
env: systemEnv,
|
||||||
|
map: MINIMAL_MAP,
|
||||||
|
rng: {
|
||||||
|
real: () => 0,
|
||||||
|
int: (min: number, _max: number) => min,
|
||||||
|
nextInt: (min: number, _max: number) => min,
|
||||||
|
next: () => 0,
|
||||||
|
nextBool: () => true,
|
||||||
|
nextRange: (min: number, _max: number) => min,
|
||||||
|
nextRangeInt: (min: number, _max: number) => min,
|
||||||
|
nextFloat1: () => 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(getEquippedItemInstance(world.getGeneral(1)!, 'item')).toBeNull();
|
||||||
|
expect(world.getGeneral(1)!.role.items.item).toBeNull();
|
||||||
|
|
||||||
|
// 5. Agitate (G1 -> C2)
|
||||||
|
reEquipStrategyItem('che_계략_이추');
|
||||||
|
const agitateDef = agitateSpec.createDefinition(strategyEnv);
|
||||||
await runner.runTurn([
|
await runner.runTurn([
|
||||||
{
|
{
|
||||||
generalId: 1,
|
generalId: 1,
|
||||||
@@ -455,9 +508,12 @@ describe('General Commands New Scenario', () => {
|
|||||||
const trust = c2_after_agitate.meta.trust as number;
|
const trust = c2_after_agitate.meta.trust as number;
|
||||||
expect(trust).toBeLessThan(100);
|
expect(trust).toBeLessThan(100);
|
||||||
expect(c2_after_agitate.security).toBeLessThan(1000);
|
expect(c2_after_agitate.security).toBeLessThan(1000);
|
||||||
|
expect(getEquippedItemInstance(world.getGeneral(1)!, 'item')).toBeNull();
|
||||||
|
|
||||||
// 5. Seize (G1 -> C2)
|
// 6. Seize (G1 -> C2)
|
||||||
const seizeDef = seizeSpec.createDefinition(systemEnv);
|
reEquipStrategyItem('che_계략_향낭');
|
||||||
|
const seizeDef = seizeSpec.createDefinition(strategyEnv);
|
||||||
|
const goldBeforeSeize = world.getGeneral(1)!.gold;
|
||||||
// Ensure C2 is supplied (nation has gold/rice)
|
// Ensure C2 is supplied (nation has gold/rice)
|
||||||
await runner.runTurn([
|
await runner.runTurn([
|
||||||
{
|
{
|
||||||
@@ -474,6 +530,7 @@ describe('General Commands New Scenario', () => {
|
|||||||
|
|
||||||
const g1_after_seize = world.getGeneral(1)!;
|
const g1_after_seize = world.getGeneral(1)!;
|
||||||
|
|
||||||
expect(g1_after_seize.gold).toBeGreaterThan(4000); // Cost deducted multiple times
|
expect(g1_after_seize.gold).toBeGreaterThan(goldBeforeSeize - systemEnv.develCost);
|
||||||
|
expect(getEquippedItemInstance(g1_after_seize, 'item')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import type { WarEngineConfig } from '../src/war/types.js';
|
|||||||
import { WarCrewType } from '../src/war/crewType.js';
|
import { WarCrewType } from '../src/war/crewType.js';
|
||||||
import { loadWarTriggerModules } from '../src/war/triggers/index.js';
|
import { loadWarTriggerModules } from '../src/war/triggers/index.js';
|
||||||
import { WarUnitCity, WarUnitGeneral } from '../src/war/units.js';
|
import { WarUnitCity, WarUnitGeneral } from '../src/war/units.js';
|
||||||
|
import {
|
||||||
|
createItemActionModules,
|
||||||
|
createItemModuleRegistry,
|
||||||
|
equipNewItem,
|
||||||
|
getEquippedItemInstance,
|
||||||
|
loadItemModules,
|
||||||
|
} from '../src/items/index.js';
|
||||||
|
|
||||||
const buildConfig = (): WarEngineConfig => ({
|
const buildConfig = (): WarEngineConfig => ({
|
||||||
armPerPhase: 500,
|
armPerPhase: 500,
|
||||||
@@ -209,6 +216,71 @@ describe('war triggers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('resolveWarBattle', () => {
|
describe('resolveWarBattle', () => {
|
||||||
|
it('persists multi-use battle item charges and removes the last charge', async () => {
|
||||||
|
const general = buildGeneral(100);
|
||||||
|
equipNewItem(general, 'item', 'event_충차', { charges: 2 });
|
||||||
|
const itemModules = createItemActionModules(
|
||||||
|
createItemModuleRegistry(await loadItemModules(['event_충차']))
|
||||||
|
).war;
|
||||||
|
|
||||||
|
for (const expectedCharges of [1, null] as const) {
|
||||||
|
general.crew = 5000;
|
||||||
|
general.rice = 10000;
|
||||||
|
const defenderCity = { ...buildCity(), wall: 3000, wallMax: 3000 };
|
||||||
|
resolveWarBattle({
|
||||||
|
rng: new RandUtil(new ConstantRNG(0)),
|
||||||
|
unitSet: buildUnitSet(),
|
||||||
|
config: buildConfig(),
|
||||||
|
time: { year: 200, month: 1, startYear: 180 },
|
||||||
|
attacker: {
|
||||||
|
general,
|
||||||
|
city: buildCity(),
|
||||||
|
nation: buildNation(),
|
||||||
|
modules: itemModules,
|
||||||
|
},
|
||||||
|
defenders: [],
|
||||||
|
defenderCity,
|
||||||
|
defenderNation: buildNation(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const equipped = getEquippedItemInstance(general, 'item');
|
||||||
|
if (expectedCharges === null) {
|
||||||
|
expect(equipped).toBeNull();
|
||||||
|
expect(general.role.items.item).toBeNull();
|
||||||
|
} else {
|
||||||
|
expect(equipped?.state.charges).toBe(expectedCharges);
|
||||||
|
expect(general.role.items.item).toBe('event_충차');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a one-use battle item through the canonical inventory', async () => {
|
||||||
|
const general = buildGeneral(100);
|
||||||
|
equipNewItem(general, 'item', 'che_저격_수극');
|
||||||
|
const itemModules = createItemActionModules(
|
||||||
|
createItemModuleRegistry(await loadItemModules(['che_저격_수극']))
|
||||||
|
).war;
|
||||||
|
|
||||||
|
resolveWarBattle({
|
||||||
|
rng: new RandUtil(new ConstantRNG(0)),
|
||||||
|
unitSet: buildUnitSet(),
|
||||||
|
config: buildConfig(),
|
||||||
|
time: { year: 200, month: 1, startYear: 180 },
|
||||||
|
attacker: {
|
||||||
|
general,
|
||||||
|
city: buildCity(),
|
||||||
|
nation: buildNation(),
|
||||||
|
modules: itemModules,
|
||||||
|
},
|
||||||
|
defenders: [],
|
||||||
|
defenderCity: buildCity(),
|
||||||
|
defenderNation: buildNation(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getEquippedItemInstance(general, 'item')).toBeNull();
|
||||||
|
expect(general.role.items.item).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('handles supply rout when defender nation has no rice', () => {
|
it('handles supply rout when defender nation has no rice', () => {
|
||||||
const rng = new RandUtil(new ConstantRNG(0));
|
const rng = new RandUtil(new ConstantRNG(0));
|
||||||
const config = buildConfig();
|
const config = buildConfig();
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
|
import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
|
||||||
import type { UnitSetDefinition, WarBattleTraceEvent, WarEngineConfig } from '@sammo-ts/logic';
|
import {
|
||||||
|
ITEM_KEYS,
|
||||||
|
loadItemModules,
|
||||||
|
type UnitSetDefinition,
|
||||||
|
type WarBattleTraceEvent,
|
||||||
|
type WarEngineConfig,
|
||||||
|
} from '@sammo-ts/logic';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { processBattleSimJob } from '../../../app/game-api/src/battleSim/processor.js';
|
import { processBattleSimJob } from '../../../app/game-api/src/battleSim/processor.js';
|
||||||
@@ -23,6 +29,16 @@ interface RandomCall {
|
|||||||
result: unknown;
|
result: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ReferenceItemMetadata {
|
||||||
|
rawName: string;
|
||||||
|
name: string;
|
||||||
|
info: string;
|
||||||
|
cost: number | null;
|
||||||
|
buyable: boolean;
|
||||||
|
consumable: boolean;
|
||||||
|
reqSecu: number;
|
||||||
|
}
|
||||||
|
|
||||||
class TracingRng implements RNG {
|
class TracingRng implements RNG {
|
||||||
public readonly calls: RandomCall[] = [];
|
public readonly calls: RandomCall[] = [];
|
||||||
|
|
||||||
@@ -83,15 +99,7 @@ const readJson = <T>(filePath: string): T => JSON.parse(fs.readFileSync(filePath
|
|||||||
const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): ReferenceTrace => {
|
const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): ReferenceTrace => {
|
||||||
const stdout = execFileSync(
|
const stdout = execFileSync(
|
||||||
'docker',
|
'docker',
|
||||||
[
|
['compose', 'exec', '-T', 'php', 'php', '/var/www/html/hwe/compare/battle_trace.php', '-'],
|
||||||
'compose',
|
|
||||||
'exec',
|
|
||||||
'-T',
|
|
||||||
'php',
|
|
||||||
'php',
|
|
||||||
'/var/www/html/hwe/compare/battle_trace.php',
|
|
||||||
'-',
|
|
||||||
],
|
|
||||||
{
|
{
|
||||||
cwd: path.join(workspaceRoot, 'docker_compose_files/reference'),
|
cwd: path.join(workspaceRoot, 'docker_compose_files/reference'),
|
||||||
input: fixtureJson,
|
input: fixtureJson,
|
||||||
@@ -102,6 +110,20 @@ const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): Referenc
|
|||||||
return JSON.parse(stdout) as ReferenceTrace;
|
return JSON.parse(stdout) as ReferenceTrace;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const runReferenceItemCatalog = (workspaceRoot: string, itemKeys: string[]): Record<string, ReferenceItemMetadata> => {
|
||||||
|
const stdout = execFileSync(
|
||||||
|
'docker',
|
||||||
|
['compose', 'exec', '-T', 'php', 'php', '/var/www/html/hwe/compare/item_catalog.php'],
|
||||||
|
{
|
||||||
|
cwd: path.join(workspaceRoot, 'docker_compose_files/reference'),
|
||||||
|
input: JSON.stringify(itemKeys),
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return JSON.parse(stdout) as Record<string, ReferenceItemMetadata>;
|
||||||
|
};
|
||||||
|
|
||||||
const expectNearlyEqual = (actual: unknown, expected: unknown, label: string): void => {
|
const expectNearlyEqual = (actual: unknown, expected: unknown, label: string): void => {
|
||||||
expect(typeof actual, `${label}: actual type`).toBe('number');
|
expect(typeof actual, `${label}: actual type`).toBe('number');
|
||||||
expect(typeof expected, `${label}: reference type`).toBe('number');
|
expect(typeof expected, `${label}: reference type`).toBe('number');
|
||||||
@@ -118,7 +140,12 @@ const assertTraceParity = (
|
|||||||
reference: ReferenceTrace,
|
reference: ReferenceTrace,
|
||||||
coreRng: TracingRng | null
|
coreRng: TracingRng | null
|
||||||
): void => {
|
): void => {
|
||||||
expect(coreEvents.map((event) => event.event)).toEqual(reference.events.map((event) => event.event));
|
const coreEventNames = coreEvents.map((event) => event.event);
|
||||||
|
const referenceEventNames = reference.events.map((event) => event.event);
|
||||||
|
expect(
|
||||||
|
coreEventNames,
|
||||||
|
`event sequence\ncore=${JSON.stringify(coreEventNames)}\nref=${JSON.stringify(referenceEventNames)}`
|
||||||
|
).toEqual(referenceEventNames);
|
||||||
expect(coreRng?.calls.map(({ operation, result }) => ({ operation, result }))).toEqual(
|
expect(coreRng?.calls.map(({ operation, result }) => ({ operation, result }))).toEqual(
|
||||||
reference.rng.map(({ operation, result }) => ({ operation, result }))
|
reference.rng.map(({ operation, result }) => ({ operation, result }))
|
||||||
);
|
);
|
||||||
@@ -147,6 +174,139 @@ const workspaceRoot = findWorkspaceRoot(process.cwd());
|
|||||||
const describeWithReference = workspaceRoot ? describe : describe.skip;
|
const describeWithReference = workspaceRoot ? describe : describe.skip;
|
||||||
|
|
||||||
describeWithReference('ref ↔ core2026 battle differential', () => {
|
describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||||
|
it('loads every scenario item with the scenario slot', async () => {
|
||||||
|
const scenarioDir = path.resolve(process.cwd(), '../../resources/scenario');
|
||||||
|
const itemSlots = new Map<string, 'horse' | 'weapon' | 'book' | 'item'>();
|
||||||
|
for (const filename of fs.readdirSync(scenarioDir).filter((entry) => entry.endsWith('.json'))) {
|
||||||
|
const scenario = readJson<{
|
||||||
|
const?: {
|
||||||
|
allItems?: Partial<Record<'horse' | 'weapon' | 'book' | 'item', Record<string, number>>>;
|
||||||
|
};
|
||||||
|
}>(path.join(scenarioDir, filename));
|
||||||
|
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
|
||||||
|
for (const itemKey of Object.keys(scenario.const?.allItems?.[slot] ?? {})) {
|
||||||
|
itemSlots.set(itemKey, slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modules = await loadItemModules([...ITEM_KEYS]);
|
||||||
|
const reference = runReferenceItemCatalog(workspaceRoot!, [...ITEM_KEYS]);
|
||||||
|
expect(modules).toHaveLength(145);
|
||||||
|
const failures: string[] = [];
|
||||||
|
for (const module of modules) {
|
||||||
|
const actual = {
|
||||||
|
rawName: module.rawName,
|
||||||
|
name: module.name,
|
||||||
|
info: module.info,
|
||||||
|
cost: module.cost,
|
||||||
|
buyable: module.buyable,
|
||||||
|
consumable: module.consumable,
|
||||||
|
reqSecu: module.reqSecu,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
module.slot !== itemSlots.get(module.key) ||
|
||||||
|
module.unique !== !reference[module.key]?.buyable ||
|
||||||
|
JSON.stringify(actual) !== JSON.stringify(reference[module.key])
|
||||||
|
) {
|
||||||
|
failures.push(
|
||||||
|
`${module.key}: slot=${module.slot}/${String(itemSlots.get(module.key))} unique=${String(module.unique)}/${String(!reference[module.key]?.buyable)} core=${JSON.stringify(actual)} ref=${JSON.stringify(reference[module.key])}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(failures, failures.join('\n')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the legacy non-stacking rule for the 무쌍 trait item', () => {
|
||||||
|
const base = readJson<BattleSimRequestPayload & { startYear: number }>(
|
||||||
|
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
|
||||||
|
);
|
||||||
|
base.seed = 'battle-differential-duplicate-musang';
|
||||||
|
base.attackerGeneral.personal = 'None';
|
||||||
|
base.attackerGeneral.special2 = 'che_무쌍';
|
||||||
|
base.attackerGeneral.item = 'event_전투특기_무쌍';
|
||||||
|
base.attackerGeneral.crew = 5000;
|
||||||
|
base.attackerGeneral.leadership = 90;
|
||||||
|
base.attackerGeneral.strength = 90;
|
||||||
|
base.attackerGeneral.intel = 90;
|
||||||
|
base.attackerGeneral.dex1 = 12000;
|
||||||
|
const unitSet = readJson<UnitSetDefinition>(
|
||||||
|
path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json')
|
||||||
|
);
|
||||||
|
const config: WarEngineConfig = {
|
||||||
|
armPerPhase: 500,
|
||||||
|
maxTrainByCommand: 100,
|
||||||
|
maxAtmosByCommand: 100,
|
||||||
|
maxTrainByWar: 110,
|
||||||
|
maxAtmosByWar: 150,
|
||||||
|
castleCrewTypeId: 1000,
|
||||||
|
armTypes: { footman: 1, archer: 2, cavalry: 3, wizard: 4, siege: 5, misc: 6, castle: 0 },
|
||||||
|
};
|
||||||
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
|
let coreRng: TracingRng | null = null;
|
||||||
|
processBattleSimJob(
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
unitSet,
|
||||||
|
config,
|
||||||
|
time: { year: base.year, month: base.month, startYear: base.startYear },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trace: (event) => coreEvents.push(event),
|
||||||
|
rngFactory: (seed) => {
|
||||||
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
|
return new RandUtil(coreRng);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assertTraceParity(coreEvents, runReferenceTrace(workspaceRoot!, JSON.stringify(base)), coreRng);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches 척사 items against region-restricted troops', () => {
|
||||||
|
const unitSet = readJson<UnitSetDefinition>(
|
||||||
|
path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json')
|
||||||
|
);
|
||||||
|
const config: WarEngineConfig = {
|
||||||
|
armPerPhase: 500,
|
||||||
|
maxTrainByCommand: 100,
|
||||||
|
maxAtmosByCommand: 100,
|
||||||
|
maxTrainByWar: 110,
|
||||||
|
maxAtmosByWar: 150,
|
||||||
|
castleCrewTypeId: 1000,
|
||||||
|
armTypes: { footman: 1, archer: 2, cavalry: 3, wizard: 4, siege: 5, misc: 6, castle: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const itemKey of ['che_척사_오악진형도', 'event_전투특기_척사']) {
|
||||||
|
const base = readJson<BattleSimRequestPayload & { startYear: number }>(
|
||||||
|
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
|
||||||
|
);
|
||||||
|
base.seed = `battle-differential-region-item-${itemKey}`;
|
||||||
|
base.attackerGeneral.personal = 'None';
|
||||||
|
base.attackerGeneral.special2 = 'None';
|
||||||
|
base.attackerGeneral.item = itemKey;
|
||||||
|
base.attackerGeneral.crew = 5000;
|
||||||
|
base.defenderGenerals[0]!.crewtype = 1101;
|
||||||
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
|
let coreRng: TracingRng | null = null;
|
||||||
|
processBattleSimJob(
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
unitSet,
|
||||||
|
config,
|
||||||
|
time: { year: base.year, month: base.month, startYear: base.startYear },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trace: (event) => coreEvents.push(event),
|
||||||
|
rngFactory: (seed) => {
|
||||||
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
|
return new RandUtil(coreRng);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assertTraceParity(coreEvents, runReferenceTrace(workspaceRoot!, JSON.stringify(base)), coreRng);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the detailed event sequence and phase values within 1%', () => {
|
it('keeps the detailed event sequence and phase values within 1%', () => {
|
||||||
const fixturePath = path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json');
|
const fixturePath = path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json');
|
||||||
const fixtureJson = fs.readFileSync(fixturePath, 'utf8');
|
const fixtureJson = fs.readFileSync(fixturePath, 'utf8');
|
||||||
@@ -429,11 +589,207 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
||||||
|
|
||||||
expect(result.result).toBe(true);
|
expect(result.result).toBe(true);
|
||||||
expect(reference.events.map((event) => event.event)).toEqual([
|
expect(reference.events.map((event) => event.event)).toEqual(['battle_start', 'supply_retreat', 'battle_end']);
|
||||||
'battle_start',
|
|
||||||
'supply_retreat',
|
|
||||||
'battle_end',
|
|
||||||
]);
|
|
||||||
assertTraceParity(coreEvents, reference, coreRng);
|
assertTraceParity(coreEvents, reference, coreRng);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('matches every scenario item in an attacker battle simulation', { timeout: 180_000 }, () => {
|
||||||
|
const scenarioDir = path.resolve(process.cwd(), '../../resources/scenario');
|
||||||
|
const itemSlots = new Map<string, 'horse' | 'weapon' | 'book' | 'item'>();
|
||||||
|
for (const filename of fs.readdirSync(scenarioDir).filter((entry) => entry.endsWith('.json'))) {
|
||||||
|
const scenario = readJson<{
|
||||||
|
const?: {
|
||||||
|
allItems?: Partial<Record<'horse' | 'weapon' | 'book' | 'item', Record<string, number>>>;
|
||||||
|
};
|
||||||
|
}>(path.join(scenarioDir, filename));
|
||||||
|
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
|
||||||
|
for (const itemKey of Object.keys(scenario.const?.allItems?.[slot] ?? {})) {
|
||||||
|
itemSlots.set(itemKey, slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(itemSlots.size).toBe(145);
|
||||||
|
expect([...ITEM_KEYS].sort()).toEqual([...itemSlots.keys()].sort());
|
||||||
|
|
||||||
|
const unitSet = readJson<UnitSetDefinition>(
|
||||||
|
path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json')
|
||||||
|
);
|
||||||
|
const config: WarEngineConfig = {
|
||||||
|
armPerPhase: 500,
|
||||||
|
maxTrainByCommand: 100,
|
||||||
|
maxAtmosByCommand: 100,
|
||||||
|
maxTrainByWar: 110,
|
||||||
|
maxAtmosByWar: 150,
|
||||||
|
castleCrewTypeId: 1000,
|
||||||
|
armTypes: {
|
||||||
|
footman: 1,
|
||||||
|
archer: 2,
|
||||||
|
cavalry: 3,
|
||||||
|
wizard: 4,
|
||||||
|
siege: 5,
|
||||||
|
misc: 6,
|
||||||
|
castle: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
const itemFilter = process.env['ITEM_PARITY_FILTER'];
|
||||||
|
for (const [itemKey, slot] of [...itemSlots].sort(([lhs], [rhs]) => lhs.localeCompare(rhs))) {
|
||||||
|
if (itemFilter && itemKey !== itemFilter) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const base = readJson<BattleSimRequestPayload & { startYear: number }>(
|
||||||
|
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
|
||||||
|
);
|
||||||
|
base.seed = `battle-differential-item-${itemKey}`;
|
||||||
|
base.attackerGeneral.personal = 'None';
|
||||||
|
base.attackerGeneral.special2 = 'None';
|
||||||
|
base.attackerGeneral.crew = 5000;
|
||||||
|
base.attackerGeneral.leadership = 90;
|
||||||
|
base.attackerGeneral.strength = 90;
|
||||||
|
base.attackerGeneral.intel = 90;
|
||||||
|
base.attackerGeneral.dex1 = 12000;
|
||||||
|
base.attackerGeneral.dex2 = 12000;
|
||||||
|
base.attackerGeneral.dex3 = 12000;
|
||||||
|
base.attackerGeneral.dex4 = 12000;
|
||||||
|
base.attackerGeneral.dex5 = 12000;
|
||||||
|
base.attackerGeneral[slot] = itemKey;
|
||||||
|
const defender = base.defenderGenerals[0]!;
|
||||||
|
defender.personal = 'None';
|
||||||
|
defender.special2 = 'None';
|
||||||
|
defender.crew = 1500;
|
||||||
|
defender.leadership = 75;
|
||||||
|
defender.strength = 75;
|
||||||
|
defender.intel = 75;
|
||||||
|
base.defenderCity.wall = 3000;
|
||||||
|
base.defenderCity.wall_max = 3000;
|
||||||
|
|
||||||
|
const payload: BattleSimJobPayload = {
|
||||||
|
...base,
|
||||||
|
unitSet,
|
||||||
|
config,
|
||||||
|
time: { year: base.year, month: base.month, startYear: base.startYear },
|
||||||
|
};
|
||||||
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
|
let coreRng: TracingRng | null = null;
|
||||||
|
processBattleSimJob(payload, {
|
||||||
|
trace: (event) => coreEvents.push(event),
|
||||||
|
rngFactory: (seed) => {
|
||||||
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
|
return new RandUtil(coreRng);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
|
||||||
|
try {
|
||||||
|
assertTraceParity(coreEvents, reference, coreRng);
|
||||||
|
} catch (error) {
|
||||||
|
const debug =
|
||||||
|
process.env['ITEM_PARITY_DEBUG'] === '1'
|
||||||
|
? `\ncore=${JSON.stringify(
|
||||||
|
coreEvents
|
||||||
|
.map((event, index) => ({ index, event }))
|
||||||
|
.filter(({ event }) => event.event === 'phase_damage')
|
||||||
|
)}\nref=${JSON.stringify(
|
||||||
|
reference.events
|
||||||
|
.map((event, index) => ({ index, event }))
|
||||||
|
.filter(({ event }) => event.event === 'phase_damage')
|
||||||
|
)}`
|
||||||
|
: '';
|
||||||
|
failures.push(`${itemKey}: ${error instanceof Error ? error.message : String(error)}${debug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(failures, failures.join('\n')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches every scenario item in a defender battle simulation', { timeout: 180_000 }, () => {
|
||||||
|
const scenarioDir = path.resolve(process.cwd(), '../../resources/scenario');
|
||||||
|
const itemSlots = new Map<string, 'horse' | 'weapon' | 'book' | 'item'>();
|
||||||
|
for (const filename of fs.readdirSync(scenarioDir).filter((entry) => entry.endsWith('.json'))) {
|
||||||
|
const scenario = readJson<{
|
||||||
|
const?: {
|
||||||
|
allItems?: Partial<Record<'horse' | 'weapon' | 'book' | 'item', Record<string, number>>>;
|
||||||
|
};
|
||||||
|
}>(path.join(scenarioDir, filename));
|
||||||
|
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
|
||||||
|
for (const itemKey of Object.keys(scenario.const?.allItems?.[slot] ?? {})) {
|
||||||
|
itemSlots.set(itemKey, slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitSet = readJson<UnitSetDefinition>(
|
||||||
|
path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json')
|
||||||
|
);
|
||||||
|
const config: WarEngineConfig = {
|
||||||
|
armPerPhase: 500,
|
||||||
|
maxTrainByCommand: 100,
|
||||||
|
maxAtmosByCommand: 100,
|
||||||
|
maxTrainByWar: 110,
|
||||||
|
maxAtmosByWar: 150,
|
||||||
|
castleCrewTypeId: 1000,
|
||||||
|
armTypes: {
|
||||||
|
footman: 1,
|
||||||
|
archer: 2,
|
||||||
|
cavalry: 3,
|
||||||
|
wizard: 4,
|
||||||
|
siege: 5,
|
||||||
|
misc: 6,
|
||||||
|
castle: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
const itemFilter = process.env['ITEM_PARITY_FILTER'];
|
||||||
|
for (const [itemKey, slot] of [...itemSlots].sort(([lhs], [rhs]) => lhs.localeCompare(rhs))) {
|
||||||
|
if (itemFilter && itemKey !== itemFilter) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const base = readJson<BattleSimRequestPayload & { startYear: number }>(
|
||||||
|
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
|
||||||
|
);
|
||||||
|
base.seed = `battle-differential-defender-item-${itemKey}`;
|
||||||
|
base.attackerGeneral.personal = 'None';
|
||||||
|
base.attackerGeneral.special2 = 'None';
|
||||||
|
base.attackerGeneral.crew = 1500;
|
||||||
|
base.attackerGeneral.leadership = 75;
|
||||||
|
base.attackerGeneral.strength = 75;
|
||||||
|
base.attackerGeneral.intel = 75;
|
||||||
|
const defender = base.defenderGenerals[0]!;
|
||||||
|
defender.personal = 'None';
|
||||||
|
defender.special2 = 'None';
|
||||||
|
defender.crew = 5000;
|
||||||
|
defender.leadership = 90;
|
||||||
|
defender.strength = 90;
|
||||||
|
defender.intel = 90;
|
||||||
|
defender.dex1 = 12000;
|
||||||
|
defender.dex2 = 12000;
|
||||||
|
defender.dex3 = 12000;
|
||||||
|
defender.dex4 = 12000;
|
||||||
|
defender.dex5 = 12000;
|
||||||
|
defender[slot] = itemKey;
|
||||||
|
|
||||||
|
const payload: BattleSimJobPayload = {
|
||||||
|
...base,
|
||||||
|
unitSet,
|
||||||
|
config,
|
||||||
|
time: { year: base.year, month: base.month, startYear: base.startYear },
|
||||||
|
};
|
||||||
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
|
let coreRng: TracingRng | null = null;
|
||||||
|
processBattleSimJob(payload, {
|
||||||
|
trace: (event) => coreEvents.push(event),
|
||||||
|
rngFactory: (seed) => {
|
||||||
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
|
return new RandUtil(coreRng);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
|
||||||
|
try {
|
||||||
|
assertTraceParity(coreEvents, reference, coreRng);
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(`${itemKey}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(failures, failures.join('\n')).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user