first commit
This commit is contained in:
18
libs/api-interfaces/project.json
Normal file
18
libs/api-interfaces/project.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "api-interfaces",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api-interfaces/src",
|
||||
"projectType": "library",
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:tsc",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/api-interfaces",
|
||||
"main": "libs/api-interfaces/src/index.ts",
|
||||
"tsConfig": "libs/api-interfaces/tsconfig.lib.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": ["scope:shared"]
|
||||
}
|
||||
5
libs/api-interfaces/src/index.ts
Normal file
5
libs/api-interfaces/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './lib/survivor';
|
||||
export * from './lib/mission';
|
||||
export * from './lib/perk';
|
||||
export * from './lib/encounter';
|
||||
export * from './lib/channel-config';
|
||||
8
libs/api-interfaces/src/lib/channel-config.ts
Normal file
8
libs/api-interfaces/src/lib/channel-config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { MissionDifficulty } from './mission';
|
||||
|
||||
export interface ChannelConfig {
|
||||
channelId: string;
|
||||
difficultyPreset: MissionDifficulty;
|
||||
maxPartySize: number;
|
||||
featureFlags: Record<string, boolean>;
|
||||
}
|
||||
19
libs/api-interfaces/src/lib/encounter.ts
Normal file
19
libs/api-interfaces/src/lib/encounter.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { MissionDifficulty, MissionState } from './mission';
|
||||
import { SurvivorStats } from './survivor';
|
||||
|
||||
export interface EncounterResult {
|
||||
outcome: 'success' | 'injury' | 'sacrifice';
|
||||
text: string;
|
||||
successChance: number;
|
||||
roll: number;
|
||||
nextState: MissionState;
|
||||
nextStats: SurvivorStats;
|
||||
}
|
||||
|
||||
export interface ResolveEncounterInput {
|
||||
stats: SurvivorStats;
|
||||
difficulty: MissionDifficulty;
|
||||
perkIds: string[];
|
||||
tickIndex: number;
|
||||
seed?: number;
|
||||
}
|
||||
35
libs/api-interfaces/src/lib/mission.ts
Normal file
35
libs/api-interfaces/src/lib/mission.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { SurvivorStats } from './survivor';
|
||||
|
||||
export enum MissionState {
|
||||
Lobby = 'Lobby',
|
||||
InProgress = 'InProgress',
|
||||
Completed = 'Completed',
|
||||
Failed = 'Failed'
|
||||
}
|
||||
|
||||
export enum MissionDifficulty {
|
||||
Easy = 'Easy',
|
||||
Normal = 'Normal',
|
||||
Hard = 'Hard',
|
||||
Nightmare = 'Nightmare'
|
||||
}
|
||||
|
||||
export interface EncounterLogLine {
|
||||
sequence: number;
|
||||
event: string;
|
||||
text: string;
|
||||
successChance: number;
|
||||
roll: number;
|
||||
}
|
||||
|
||||
export interface MissionSnapshot {
|
||||
id: string;
|
||||
channelId: string;
|
||||
survivorId: string;
|
||||
state: MissionState;
|
||||
difficulty: MissionDifficulty;
|
||||
tickIndex: number;
|
||||
recentLog: EncounterLogLine[];
|
||||
stats: SurvivorStats;
|
||||
perkIds: string[];
|
||||
}
|
||||
19
libs/api-interfaces/src/lib/perk.ts
Normal file
19
libs/api-interfaces/src/lib/perk.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export enum ModifierKind {
|
||||
Additive = 'additive',
|
||||
Multiplicative = 'multiplicative',
|
||||
FlatReroll = 'flat_reroll'
|
||||
}
|
||||
|
||||
export interface PerkModifier {
|
||||
id: string;
|
||||
kind: ModifierKind;
|
||||
value: number;
|
||||
teamPerk?: boolean;
|
||||
}
|
||||
|
||||
export interface Perk {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
modifiers: PerkModifier[];
|
||||
}
|
||||
18
libs/api-interfaces/src/lib/survivor.ts
Normal file
18
libs/api-interfaces/src/lib/survivor.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export enum SurvivorState {
|
||||
Active = 'Active',
|
||||
Injured = 'Injured',
|
||||
Sacrificed = 'Sacrificed'
|
||||
}
|
||||
|
||||
export interface SurvivorStats {
|
||||
health: number;
|
||||
stealth: number;
|
||||
teamwork: number;
|
||||
luck: number;
|
||||
}
|
||||
|
||||
export interface Survivor {
|
||||
id: string;
|
||||
state: SurvivorState;
|
||||
stats: SurvivorStats;
|
||||
}
|
||||
9
libs/api-interfaces/tsconfig.lib.json
Normal file
9
libs/api-interfaces/tsconfig.lib.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
24
libs/encounter-library/project.json
Normal file
24
libs/encounter-library/project.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "encounter-library",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/encounter-library/src",
|
||||
"projectType": "library",
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:tsc",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/encounter-library",
|
||||
"main": "libs/encounter-library/src/index.ts",
|
||||
"tsConfig": "libs/encounter-library/tsconfig.lib.json"
|
||||
}
|
||||
},
|
||||
"validate": {
|
||||
"executor": "@nx/workspace:run-commands",
|
||||
"options": {
|
||||
"command": "ts-node libs/encounter-library/src/lib/validate-encounters.ts"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": ["scope:content"]
|
||||
}
|
||||
1
libs/encounter-library/src/index.ts
Normal file
1
libs/encounter-library/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/encounter-library';
|
||||
42
libs/encounter-library/src/lib/encounter-library.ts
Normal file
42
libs/encounter-library/src/lib/encounter-library.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import encountersData from './encounters.json';
|
||||
|
||||
export interface EncounterRecord {
|
||||
id: string;
|
||||
tier: string;
|
||||
baseSuccessChance: number;
|
||||
difficultyTags: string[];
|
||||
perkTags: string[];
|
||||
flavor: string[];
|
||||
}
|
||||
|
||||
interface EncounterDocument {
|
||||
schemaVersion: number;
|
||||
records: EncounterRecord[];
|
||||
}
|
||||
|
||||
const doc = encountersData as EncounterDocument;
|
||||
|
||||
export function listEncounters(): EncounterRecord[] {
|
||||
return doc.records;
|
||||
}
|
||||
|
||||
export function getEncounterById(id: string): EncounterRecord | undefined {
|
||||
return doc.records.find((record) => record.id === id);
|
||||
}
|
||||
|
||||
export function getRandomEncounterByTier(tier: string, seed = Date.now()): EncounterRecord {
|
||||
const matching = doc.records.filter((record) => record.tier === tier);
|
||||
const pool = matching.length > 0 ? matching : doc.records;
|
||||
const index = Math.abs(seed) % pool.length;
|
||||
return pool[index];
|
||||
}
|
||||
|
||||
export function migrateEncounterDocument(document: EncounterDocument): EncounterDocument {
|
||||
if (document.schemaVersion === 1) {
|
||||
return document;
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
records: document.records
|
||||
};
|
||||
}
|
||||
45
libs/encounter-library/src/lib/encounter.schema.json
Normal file
45
libs/encounter-library/src/lib/encounter.schema.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "records"],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"records": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"tier",
|
||||
"baseSuccessChance",
|
||||
"difficultyTags",
|
||||
"perkTags",
|
||||
"flavor"
|
||||
],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"tier": { "type": "string" },
|
||||
"baseSuccessChance": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"difficultyTags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"perkTags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"flavor": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"minItems": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
27
libs/encounter-library/src/lib/encounters.json
Normal file
27
libs/encounter-library/src/lib/encounters.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"records": [
|
||||
{
|
||||
"id": "cleanse_hex",
|
||||
"tier": "common",
|
||||
"baseSuccessChance": 0.62,
|
||||
"difficultyTags": ["normal", "hard"],
|
||||
"perkTags": ["totem", "support"],
|
||||
"flavor": [
|
||||
"The bones glow with cursed light.",
|
||||
"You cleanse the totem before the killer returns."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "escape_hatch",
|
||||
"tier": "rare",
|
||||
"baseSuccessChance": 0.41,
|
||||
"difficultyTags": ["hard", "nightmare"],
|
||||
"perkTags": ["mobility", "luck"],
|
||||
"flavor": [
|
||||
"A rusted hatch creaks in the distance.",
|
||||
"One wrong step and the fog closes in."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
27
libs/encounter-library/src/lib/validate-encounters.ts
Normal file
27
libs/encounter-library/src/lib/validate-encounters.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
const encounterRecord = z.object({
|
||||
id: z.string().min(1),
|
||||
tier: z.string(),
|
||||
baseSuccessChance: z.number().min(0).max(1),
|
||||
difficultyTags: z.array(z.string()),
|
||||
perkTags: z.array(z.string()),
|
||||
flavor: z.array(z.string()).min(1)
|
||||
});
|
||||
|
||||
const encounterDocument = z.object({
|
||||
schemaVersion: z.number().int().min(1),
|
||||
records: z.array(encounterRecord)
|
||||
});
|
||||
|
||||
function main(): void {
|
||||
const file = resolve(process.cwd(), 'libs/encounter-library/src/lib/encounters.json');
|
||||
const raw = readFileSync(file, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
encounterDocument.parse(parsed);
|
||||
process.stdout.write('Encounter library validation passed.\n');
|
||||
}
|
||||
|
||||
main();
|
||||
9
libs/encounter-library/tsconfig.lib.json
Normal file
9
libs/encounter-library/tsconfig.lib.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.json"]
|
||||
}
|
||||
24
libs/mission-logic/project.json
Normal file
24
libs/mission-logic/project.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "mission-logic",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/mission-logic/src",
|
||||
"projectType": "library",
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:tsc",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/mission-logic",
|
||||
"main": "libs/mission-logic/src/index.ts",
|
||||
"tsConfig": "libs/mission-logic/tsconfig.lib.json"
|
||||
}
|
||||
},
|
||||
"simulate": {
|
||||
"executor": "@nx/workspace:run-commands",
|
||||
"options": {
|
||||
"command": "ts-node libs/mission-logic/src/simulator/cli.ts"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": ["scope:logic"]
|
||||
}
|
||||
3
libs/mission-logic/src/index.ts
Normal file
3
libs/mission-logic/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './lib/encounter-resolver';
|
||||
export * from './lib/perk-math';
|
||||
export * from './lib/group-synergy.service';
|
||||
74
libs/mission-logic/src/lib/encounter-resolver.ts
Normal file
74
libs/mission-logic/src/lib/encounter-resolver.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
EncounterResult,
|
||||
MissionDifficulty,
|
||||
MissionState,
|
||||
ResolveEncounterInput,
|
||||
SurvivorStats
|
||||
} from '@fog-explorer/api-interfaces';
|
||||
|
||||
import { applyPerkModifiers, clamp } from './perk-math';
|
||||
|
||||
const DIFFICULTY_BASE: Record<MissionDifficulty, number> = {
|
||||
[MissionDifficulty.Easy]: 0.72,
|
||||
[MissionDifficulty.Normal]: 0.6,
|
||||
[MissionDifficulty.Hard]: 0.48,
|
||||
[MissionDifficulty.Nightmare]: 0.38
|
||||
};
|
||||
|
||||
export function resolveEncounter(input: ResolveEncounterInput): EncounterResult {
|
||||
const baseChance = DIFFICULTY_BASE[input.difficulty];
|
||||
const perkBoost = input.perkIds.length * 0.015;
|
||||
const statBoost =
|
||||
input.stats.stealth * 0.002 + input.stats.teamwork * 0.002 + input.stats.luck * 0.002;
|
||||
const successChance = clamp(
|
||||
applyPerkModifiers(baseChance + perkBoost + statBoost, []),
|
||||
0.05,
|
||||
0.95
|
||||
);
|
||||
const roll = seededRoll(input.seed ?? input.tickIndex);
|
||||
|
||||
if (roll <= successChance) {
|
||||
return {
|
||||
outcome: 'success',
|
||||
text: 'The survivor outplayed the fog and advanced.',
|
||||
successChance,
|
||||
roll,
|
||||
nextState: MissionState.InProgress,
|
||||
nextStats: { ...input.stats, health: Math.min(100, input.stats.health + 1) }
|
||||
};
|
||||
}
|
||||
|
||||
const injuryThreshold = successChance + 0.25;
|
||||
if (roll <= injuryThreshold) {
|
||||
const injured = applyDamage(input.stats, 14);
|
||||
return {
|
||||
outcome: 'injury',
|
||||
text: 'A close call leaves the survivor injured.',
|
||||
successChance,
|
||||
roll,
|
||||
nextState: injured.health <= 0 ? MissionState.Failed : MissionState.InProgress,
|
||||
nextStats: injured
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
outcome: 'sacrifice',
|
||||
text: 'The fog claims another soul.',
|
||||
successChance,
|
||||
roll,
|
||||
nextState: MissionState.Failed,
|
||||
nextStats: { ...input.stats, health: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
function applyDamage(stats: SurvivorStats, amount: number): SurvivorStats {
|
||||
return {
|
||||
...stats,
|
||||
health: Math.max(0, stats.health - amount)
|
||||
};
|
||||
}
|
||||
|
||||
function seededRoll(seed: number): number {
|
||||
const normalized = Math.abs(Math.sin(seed * 99991)) * 10000;
|
||||
return normalized - Math.floor(normalized);
|
||||
}
|
||||
15
libs/mission-logic/src/lib/group-synergy.service.ts
Normal file
15
libs/mission-logic/src/lib/group-synergy.service.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { PerkModifier } from '@fog-explorer/api-interfaces';
|
||||
|
||||
export interface GroupContext {
|
||||
memberCount: number;
|
||||
teamModifiers: PerkModifier[];
|
||||
}
|
||||
|
||||
export class GroupSynergyService {
|
||||
buildContext(teamModifiers: PerkModifier[]): GroupContext {
|
||||
return {
|
||||
memberCount: Math.max(1, Math.min(4, teamModifiers.length || 1)),
|
||||
teamModifiers
|
||||
};
|
||||
}
|
||||
}
|
||||
18
libs/mission-logic/src/lib/perk-math.ts
Normal file
18
libs/mission-logic/src/lib/perk-math.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ModifierKind, PerkModifier } from '@fog-explorer/api-interfaces';
|
||||
|
||||
export function applyPerkModifiers(baseChance: number, modifiers: PerkModifier[]): number {
|
||||
let chance = baseChance;
|
||||
for (const modifier of modifiers) {
|
||||
if (modifier.kind === ModifierKind.Additive) {
|
||||
chance += modifier.value;
|
||||
}
|
||||
if (modifier.kind === ModifierKind.Multiplicative) {
|
||||
chance *= modifier.value;
|
||||
}
|
||||
}
|
||||
return clamp(chance, 0.05, 0.95);
|
||||
}
|
||||
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
67
libs/mission-logic/src/simulator/cli.ts
Normal file
67
libs/mission-logic/src/simulator/cli.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { MissionDifficulty, MissionState, SurvivorStats } from '@fog-explorer/api-interfaces';
|
||||
|
||||
import { resolveEncounter } from '../lib/encounter-resolver';
|
||||
|
||||
function parseArg(name: string, fallback: string): string {
|
||||
const key = `--${name}=`;
|
||||
const arg = process.argv.find((x) => x.startsWith(key));
|
||||
return arg ? arg.slice(key.length) : fallback;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const runs = Number(parseArg('runs', '10000'));
|
||||
const seed = Number(parseArg('seed', '42'));
|
||||
const difficulty = parseArg('difficulty', MissionDifficulty.Normal) as MissionDifficulty;
|
||||
|
||||
let successes = 0;
|
||||
let injuries = 0;
|
||||
let sacrifices = 0;
|
||||
const lengths: number[] = [];
|
||||
|
||||
for (let run = 0; run < runs; run += 1) {
|
||||
let state = MissionState.InProgress;
|
||||
let tick = 0;
|
||||
let stats: SurvivorStats = { health: 100, stealth: 10, teamwork: 10, luck: 10 };
|
||||
while (state === MissionState.InProgress && tick < 40) {
|
||||
tick += 1;
|
||||
const result = resolveEncounter({
|
||||
stats,
|
||||
difficulty,
|
||||
perkIds: [],
|
||||
tickIndex: tick,
|
||||
seed: seed + run * 101 + tick
|
||||
});
|
||||
if (result.outcome === 'success') successes += 1;
|
||||
if (result.outcome === 'injury') injuries += 1;
|
||||
if (result.outcome === 'sacrifice') sacrifices += 1;
|
||||
state = result.nextState;
|
||||
stats = result.nextStats;
|
||||
}
|
||||
lengths.push(tick);
|
||||
}
|
||||
|
||||
lengths.sort((a, b) => a - b);
|
||||
const p50 = lengths[Math.floor(lengths.length * 0.5)];
|
||||
const p90 = lengths[Math.floor(lengths.length * 0.9)];
|
||||
const p99 = lengths[Math.floor(lengths.length * 0.99)];
|
||||
|
||||
const totalEvents = successes + injuries + sacrifices;
|
||||
const pct = (n: number) => ((n / totalEvents) * 100).toFixed(2);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
runs,
|
||||
difficulty,
|
||||
successRatePct: pct(successes),
|
||||
injuryRatePct: pct(injuries),
|
||||
sacrificeRatePct: pct(sacrifices),
|
||||
missionLengthPercentiles: { p50, p90, p99 }
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
10
libs/mission-logic/tsconfig.lib.json
Normal file
10
libs/mission-logic/tsconfig.lib.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"rootDir": "../../",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user