Merge branch 'main' into feature/general-icon-names
This commit is contained in:
@@ -47,6 +47,71 @@ export const RANK_DATA_TYPES = [
|
||||
|
||||
export type RankDataType = (typeof RANK_DATA_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Legacy `sammo\Enums\RankColumn` values stored in `rank_data`.
|
||||
*
|
||||
* `experience`, `dedication`, and `dex1` through `dex5` are natural general
|
||||
* columns in the reference implementation. core2026 currently keeps mirrored
|
||||
* rank rows for those values as a compatibility cache, but differential
|
||||
* snapshots must compare this legacy set rather than treating the mirrors as
|
||||
* source-of-truth rows.
|
||||
*/
|
||||
export const LEGACY_RANK_DATA_TYPES = [
|
||||
'firenum',
|
||||
'warnum',
|
||||
'killnum',
|
||||
'deathnum',
|
||||
'killcrew',
|
||||
'deathcrew',
|
||||
'ttw',
|
||||
'ttd',
|
||||
'ttl',
|
||||
'ttg',
|
||||
'ttp',
|
||||
'tlw',
|
||||
'tld',
|
||||
'tll',
|
||||
'tlg',
|
||||
'tlp',
|
||||
'tsw',
|
||||
'tsd',
|
||||
'tsl',
|
||||
'tsg',
|
||||
'tsp',
|
||||
'tiw',
|
||||
'tid',
|
||||
'til',
|
||||
'tig',
|
||||
'tip',
|
||||
'betwin',
|
||||
'betgold',
|
||||
'betwingold',
|
||||
'killcrew_person',
|
||||
'deathcrew_person',
|
||||
'occupied',
|
||||
'inherit_earned',
|
||||
'inherit_spent',
|
||||
'inherit_earned_dyn',
|
||||
'inherit_earned_act',
|
||||
'inherit_spent_dyn',
|
||||
] as const satisfies readonly RankDataType[];
|
||||
|
||||
export type LegacyRankDataType = (typeof LEGACY_RANK_DATA_TYPES)[number];
|
||||
|
||||
const PREFIXED_RANK_DATA_TYPES = new Set<RankDataType>([
|
||||
'warnum',
|
||||
'killnum',
|
||||
'deathnum',
|
||||
'occupied',
|
||||
'killcrew',
|
||||
'deathcrew',
|
||||
'killcrew_person',
|
||||
'deathcrew_person',
|
||||
]);
|
||||
|
||||
export const rankDataMetaKey = (type: RankDataType): string =>
|
||||
PREFIXED_RANK_DATA_TYPES.has(type) ? `rank_${type}` : type;
|
||||
|
||||
export const HALL_OF_FAME_TYPES = [
|
||||
'experience',
|
||||
'dedication',
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface TurnCommandEnv {
|
||||
baseRice: number;
|
||||
generalMinimumGold?: number;
|
||||
generalMinimumRice?: number;
|
||||
npcSeizureMessageProb?: number;
|
||||
maxResourceActionAmount: number;
|
||||
itemCatalog?: Record<string, TurnCommandItemCatalogEntry>;
|
||||
generalActionModules?: Array<GeneralActionModule>;
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
|
||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
import { JosaUtil, LEGACY_RANK_DATA_TYPES, rankDataMetaKey } from '@sammo-ts/common';
|
||||
|
||||
export interface RetireArgs {}
|
||||
|
||||
@@ -22,7 +22,6 @@ const ACTION_NAME = '은퇴';
|
||||
const ACTION_KEY = 'che_은퇴';
|
||||
|
||||
const REQ_AGE = 60;
|
||||
|
||||
const reqGeneralValue = (): Constraint => ({
|
||||
name: 'reqGeneralValue',
|
||||
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
||||
@@ -51,46 +50,8 @@ export class ActionResolver<
|
||||
}
|
||||
nextMeta.specAge = 0;
|
||||
nextMeta.specAge2 = 0;
|
||||
nextMeta.firenum = 0;
|
||||
for (const key of [
|
||||
'warnum',
|
||||
'killnum',
|
||||
'deathnum',
|
||||
'killcrew',
|
||||
'deathcrew',
|
||||
'ttw',
|
||||
'ttd',
|
||||
'ttl',
|
||||
'ttg',
|
||||
'ttp',
|
||||
'tlw',
|
||||
'tld',
|
||||
'tll',
|
||||
'tlg',
|
||||
'tlp',
|
||||
'tsw',
|
||||
'tsd',
|
||||
'tsl',
|
||||
'tsg',
|
||||
'tsp',
|
||||
'tiw',
|
||||
'tid',
|
||||
'til',
|
||||
'tig',
|
||||
'tip',
|
||||
'betwin',
|
||||
'betgold',
|
||||
'betwingold',
|
||||
'killcrew_person',
|
||||
'deathcrew_person',
|
||||
'occupied',
|
||||
'inherit_earned',
|
||||
'inherit_spent',
|
||||
'inherit_earned_dyn',
|
||||
'inherit_earned_act',
|
||||
'inherit_spent_dyn',
|
||||
]) {
|
||||
nextMeta[`rank_${key}`] = 0;
|
||||
for (const type of LEGACY_RANK_DATA_TYPES) {
|
||||
nextMeta[rankDataMetaKey(type)] = 0;
|
||||
}
|
||||
|
||||
const josaYi = JosaUtil.pick(general.name, '이');
|
||||
|
||||
@@ -15,7 +15,12 @@ import type {
|
||||
GeneralActionOutcome,
|
||||
GeneralActionResolveContext,
|
||||
} from '@sammo-ts/logic/actions/engine.js';
|
||||
import { createLogEffect, createNationPatchEffect, createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||
import {
|
||||
createGeneralPatchEffect,
|
||||
createLogEffect,
|
||||
createMessageEffect,
|
||||
createNationPatchEffect,
|
||||
} from '@sammo-ts/logic/actions/engine.js';
|
||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||
@@ -37,9 +42,40 @@ export interface SeizureResolveContext<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> extends GeneralActionResolveContext<TriggerState> {
|
||||
destGeneral: General<TriggerState>;
|
||||
messageTime: Date;
|
||||
}
|
||||
|
||||
const ACTION_NAME = '몰수';
|
||||
const NPC_SEIZURE_MESSAGE_PROB = 0.01;
|
||||
const NPC_SEIZURE_MESSAGES = [
|
||||
'몰수를 하다니... 이것이 윗사람이 할 짓이란 말입니까...',
|
||||
'사유재산까지 몰수해가면서 이 나라가 잘 될거라 믿습니까? 정말 이해할 수가 없군요...',
|
||||
'내 돈 내놔라! 내 돈! 몰수가 웬 말이냐!',
|
||||
'몰수해간 내 자금... 언젠가 몰래 다시 빼내올 것이다...',
|
||||
'몰수로 인한 사기 저하는 몰수로 얻은 물자보다 더 손해란걸 모른단 말인가!',
|
||||
] as const;
|
||||
|
||||
type InclusiveRandomGenerator = GeneralActionResolveContext['rng'] & {
|
||||
nextIntInclusive?: (maxInclusive: number) => number;
|
||||
};
|
||||
|
||||
const pickLegacyNpcMessage = (rng: GeneralActionResolveContext['rng']): string => {
|
||||
const inclusive = rng as InclusiveRandomGenerator;
|
||||
const index = inclusive.nextIntInclusive
|
||||
? inclusive.nextIntInclusive(NPC_SEIZURE_MESSAGES.length - 1)
|
||||
: rng.nextInt(0, NPC_SEIZURE_MESSAGES.length);
|
||||
return NPC_SEIZURE_MESSAGES[index]!;
|
||||
};
|
||||
|
||||
const resolveGeneralIcon = (general: General): string => {
|
||||
const runtimePicture = (general as General & { picture?: unknown }).picture;
|
||||
const rawPicture = runtimePicture ?? general.meta.picture;
|
||||
const picture =
|
||||
(typeof rawPicture === 'string' && rawPicture !== '') || typeof rawPicture === 'number'
|
||||
? String(rawPicture)
|
||||
: 'default.jpg';
|
||||
return `/image/icons/${picture}`;
|
||||
};
|
||||
|
||||
export class ActionDefinition<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
@@ -149,6 +185,30 @@ export class ActionDefinition<
|
||||
}),
|
||||
];
|
||||
|
||||
if (
|
||||
destGeneral.npcState >= 2 &&
|
||||
context.rng.nextBool(this.env.npcSeizureMessageProb ?? NPC_SEIZURE_MESSAGE_PROB)
|
||||
) {
|
||||
const target = {
|
||||
generalId: destGeneral.id,
|
||||
generalName: destGeneral.name,
|
||||
nationId: nation.id,
|
||||
nationName: nation.name,
|
||||
color: nation.color,
|
||||
icon: resolveGeneralIcon(destGeneral),
|
||||
};
|
||||
effects.push(
|
||||
createMessageEffect({
|
||||
msgType: 'public',
|
||||
src: target,
|
||||
dest: target,
|
||||
text: pickLegacyNpcMessage(context.rng),
|
||||
time: context.messageTime,
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return { effects };
|
||||
}
|
||||
}
|
||||
@@ -166,6 +226,7 @@ export const actionContextBuilder: ActionContextBuilder<SeizureArgs> = (base, op
|
||||
return {
|
||||
...base,
|
||||
destGeneral,
|
||||
messageTime: base.general.turnTime,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -124,8 +124,11 @@ export const parsePercent = (value: string): number | null => {
|
||||
export type CompareOperator = '>' | '>=' | '==' | '<=' | '<' | '!=' | '===' | '!==';
|
||||
|
||||
export const compareValues = (target: unknown, op: CompareOperator, source: unknown): boolean => {
|
||||
const lhs = target as any;
|
||||
const rhs = source as any;
|
||||
// The cast is type-only: JavaScript still applies its native relational
|
||||
// coercion rules to the original runtime values, matching the legacy
|
||||
// constraint evaluator without opting the whole comparison into `any`.
|
||||
const lhs = target as number;
|
||||
const rhs = source as number;
|
||||
switch (op) {
|
||||
case '<':
|
||||
return lhs < rhs;
|
||||
|
||||
@@ -196,10 +196,7 @@ const resolveUnitReport = (unit: WarUnit): WarUnitReport => {
|
||||
};
|
||||
};
|
||||
|
||||
const buildTraceUnitSnapshot = (
|
||||
unit: WarUnit,
|
||||
defenderCity: City
|
||||
): WarBattleTraceUnitSnapshot => {
|
||||
const buildTraceUnitSnapshot = (unit: WarUnit, defenderCity: City): WarBattleTraceUnitSnapshot => {
|
||||
const common = {
|
||||
kind: unit instanceof WarUnitGeneral ? ('general' as const) : ('city' as const),
|
||||
id: unit instanceof WarUnitGeneral ? unit.getGeneral().id : (unit as WarUnitCity).getCityId(),
|
||||
@@ -339,7 +336,6 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
);
|
||||
|
||||
const iter = defenderUnits.values();
|
||||
let defender: WarUnit<TriggerState> | null = null;
|
||||
|
||||
const getNextDefender = (
|
||||
_prevDefender: WarUnit<TriggerState> | null,
|
||||
@@ -359,7 +355,7 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
return candidate;
|
||||
};
|
||||
|
||||
defender = getNextDefender(null, true);
|
||||
let defender = getNextDefender(null, true);
|
||||
let conquerCity = false;
|
||||
let logWritten = false;
|
||||
let traceSeq = 0;
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('migrated general commands', () => {
|
||||
expect(updatedLord.experience).toBe(700);
|
||||
});
|
||||
|
||||
it('che_증여: 최소 보유량을 넘는 자원만 이전한다', async () => {
|
||||
it('che_증여: 금은 레거시 최소 보유량 0을 적용해 요청한 금액을 이전한다', async () => {
|
||||
const actor = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '증여자', gold: 1300 });
|
||||
const dest = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '수령자', gold: 200 });
|
||||
const nation = makeNation({ id: 1, name: '오', chiefGeneralId: 1, capitalCityId: 1, level: 1 });
|
||||
@@ -246,8 +246,8 @@ describe('migrated general commands', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(world.getGeneral(actor.id)!.gold).toBe(1000);
|
||||
expect(world.getGeneral(dest.id)!.gold).toBe(500);
|
||||
expect(world.getGeneral(actor.id)!.gold).toBe(800);
|
||||
expect(world.getGeneral(dest.id)!.gold).toBe(700);
|
||||
});
|
||||
|
||||
it('che_해산: 방랑군 해산 시 세력과 소속을 정리한다', async () => {
|
||||
|
||||
@@ -258,7 +258,6 @@ describe('General Commands New Scenario', () => {
|
||||
|
||||
// 6. Retire (Needs age >= 60)
|
||||
// Manually set age
|
||||
// Manually set age
|
||||
const gToRetire = { ...g1_after_resign, age: 65 };
|
||||
world.snapshot.generals = world.snapshot.generals.map((g) => (g.id === 1 ? gToRetire : g));
|
||||
const retireDef = retireSpec.createDefinition(systemEnv);
|
||||
@@ -274,7 +273,7 @@ describe('General Commands New Scenario', () => {
|
||||
const g1_after_retire = world.getGeneral(1)!;
|
||||
expect(g1_after_retire.age).toBe(20);
|
||||
// General::rebirth()는 앞선 명령으로 누적된 경험을 초기화하지 않고 절반으로 줄인다.
|
||||
expect(g1_after_retire.experience).toBe(142);
|
||||
expect(g1_after_retire.experience).toBe(Math.round(gToRetire.experience * 0.5));
|
||||
});
|
||||
|
||||
it('should execute employ and sabotage commands', async () => {
|
||||
|
||||
Reference in New Issue
Block a user