phase 6: escalation effects + enhanced crisis system

- Add escalation effects module: creature speed/aggro/damage multipliers,
  reaction instability, environmental damage — all scale 0→1
- Add getCrisisPlayerDamage/getCrisisTint to crisis module
- Integrate escalation effects into GameScene (env damage at high entropy)
- Dynamic crisis overlay tint that intensifies with progress
- Phase indicator shows entropy %, aggression multiplier, crisis status
- 14 new tests for escalation + crisis damage/tint (349 total)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Денис Шкабатур
2026-02-12 15:24:48 +03:00
parent 3d4f710cb0
commit 493748f2b0
4 changed files with 241 additions and 5 deletions

View File

@@ -83,3 +83,23 @@ export function attemptNeutralize(
export function isCrisisResolved(crisis: CrisisState): boolean {
return crisis.resolved;
}
/** Calculate player damage per frame from active crisis */
export function getCrisisPlayerDamage(crisis: CrisisState, deltaMs: number): number {
if (crisis.resolved || crisis.progress <= 0.3) return 0;
const deltaSec = deltaMs / 1000;
// Damage scales with how far above 30% threshold
return (crisis.progress - 0.3) * 0.7 * deltaSec;
}
/** Get visual tint for crisis overlay */
export function getCrisisTint(crisis: CrisisState): { color: number; alpha: number } {
if (crisis.resolved || crisis.progress <= 0) {
return { color: 0x88ff88, alpha: 0 };
}
// Green toxic tint that intensifies with progress
return {
color: 0x88ff88,
alpha: Math.min(0.15, crisis.progress * 0.15),
};
}

48
src/run/escalation.ts Normal file
View File

@@ -0,0 +1,48 @@
/**
* Escalation Effects — how rising entropy changes the game world.
*
* GDD spec:
* "Entropy grows. Temperature fluctuates harder, chemical reactions become
* more unstable, creatures more aggressive, NPCs more paranoid."
*
* Escalation (0.0 → 1.0) modifies:
* - Creature speed, aggro range, attack damage
* - Reaction instability (chance of unexpected side effects)
* - Environmental damage (toxic atmosphere)
*/
/** Multipliers and modifiers based on escalation level */
export interface EscalationEffects {
/** Creature movement speed multiplier (1.0 → 1.5) */
creatureSpeedMultiplier: number;
/** Creature aggro detection range multiplier (1.0 → 1.8) */
creatureAggroRange: number;
/** Creature attack damage multiplier (1.0 → 1.6) */
creatureAttackMultiplier: number;
/** Reaction instability: chance 00.3 of unexpected side effects */
reactionInstability: number;
/** Environmental damage per second at current escalation */
environmentalDamage: number;
}
/**
* Calculate escalation effects from escalation level (0.01.0).
* All effects scale linearly from neutral to maximum.
*/
export function getEscalationEffects(escalation: number): EscalationEffects {
// Clamp to [0, 1]
const t = Math.max(0, Math.min(1, escalation));
return {
creatureSpeedMultiplier: lerp(1.0, 1.5, t),
creatureAggroRange: lerp(1.0, 1.8, t),
creatureAttackMultiplier: lerp(1.0, 1.6, t),
reactionInstability: lerp(0, 0.3, t),
environmentalDamage: t > 0.6 ? lerp(0, 0.5, (t - 0.6) / 0.4) : 0,
};
}
/** Linear interpolation */
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}

View File

@@ -52,9 +52,12 @@ import {
createCrisisState,
applyCrisisDamage,
attemptNeutralize,
getCrisisPlayerDamage,
getCrisisTint,
CHEMICAL_PLAGUE,
type CrisisState,
} from '../run/crisis';
import { getEscalationEffects } from '../run/escalation';
export class GameScene extends Phaser.Scene {
private gameWorld!: GameWorld;
@@ -396,14 +399,26 @@ export class GameScene extends Phaser.Scene {
// 9g. Crisis damage (if active)
if (this.crisisState?.active) {
applyCrisisDamage(this.crisisState, delta);
// Crisis damages player slowly
if (this.crisisState.progress > 0.3) {
const crisisDmg = this.crisisState.progress * 0.5 * (delta / 1000);
const crisisDmg = getCrisisPlayerDamage(this.crisisState, delta);
if (crisisDmg > 0) {
Health.current[this.playerEid] = Math.max(
0,
(Health.current[this.playerEid] ?? 0) - crisisDmg,
);
}
// Update crisis visual tint
const tint = getCrisisTint(this.crisisState);
this.crisisOverlay.setFillStyle(tint.color, tint.alpha);
}
// 9h. Environmental damage from high escalation
const escalationFx = getEscalationEffects(this.runState.escalation);
if (escalationFx.environmentalDamage > 0) {
const envDmg = escalationFx.environmentalDamage * (delta / 1000);
Health.current[this.playerEid] = Math.max(
0,
(Health.current[this.playerEid] ?? 0) - envDmg,
);
}
// 10. Health / death
@@ -493,8 +508,14 @@ export class GameScene extends Phaser.Scene {
// Update phase indicator
const phaseName = RUN_PHASE_NAMES_RU[this.runState.phase];
const escalationPct = Math.round(this.runState.escalation * 100);
const crisisInfo = this.crisisState?.active ? ` | ЧУМА: ${Math.round(this.crisisState.progress * 100)}%` : '';
this.phaseText.setText(`${phaseName} | Эскалация: ${escalationPct}%${crisisInfo}`);
const crisisInfo = this.crisisState?.active
? ` | ☠ ЧУМА: ${Math.round(this.crisisState.progress * 100)}%`
: this.runState.crisisResolved ? ' | ✓ Чума нейтрализована' : '';
const escFx = getEscalationEffects(this.runState.escalation);
const speedInfo = escFx.creatureSpeedMultiplier > 1.05
? ` | Агрессия: ×${escFx.creatureAttackMultiplier.toFixed(1)}`
: '';
this.phaseText.setText(`${phaseName} | Энтропия: ${escalationPct}%${speedInfo}${crisisInfo}`);
// Color phase text based on danger
if (this.runState.phase >= RunPhase.Crisis) {

147
tests/escalation.test.ts Normal file
View File

@@ -0,0 +1,147 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type { RunState } from '../src/run/types';
import { RunPhase, ESCALATION_RATE } from '../src/run/types';
import { createRunState, advancePhase, updateEscalation } from '../src/run/state';
import {
getEscalationEffects,
type EscalationEffects,
} from '../src/run/escalation';
import {
createCrisisState,
applyCrisisDamage,
attemptNeutralize,
isCrisisResolved,
getCrisisPlayerDamage,
getCrisisTint,
CHEMICAL_PLAGUE,
} from '../src/run/crisis';
// ─── Escalation Effects ──────────────────────────────────────────
describe('Escalation Effects', () => {
it('at 0 escalation, all multipliers are 1.0', () => {
const fx = getEscalationEffects(0);
expect(fx.creatureSpeedMultiplier).toBe(1.0);
expect(fx.creatureAggroRange).toBe(1.0);
expect(fx.creatureAttackMultiplier).toBe(1.0);
expect(fx.reactionInstability).toBe(0);
expect(fx.environmentalDamage).toBe(0);
});
it('at 0.5 escalation, effects are moderate', () => {
const fx = getEscalationEffects(0.5);
expect(fx.creatureSpeedMultiplier).toBeGreaterThan(1.0);
expect(fx.creatureSpeedMultiplier).toBeLessThan(2.0);
expect(fx.creatureAggroRange).toBeGreaterThan(1.0);
expect(fx.creatureAttackMultiplier).toBeGreaterThan(1.0);
expect(fx.reactionInstability).toBeGreaterThan(0);
});
it('at 1.0 escalation, effects are maximal', () => {
const fx = getEscalationEffects(1.0);
expect(fx.creatureSpeedMultiplier).toBe(1.5);
expect(fx.creatureAggroRange).toBe(1.8);
expect(fx.creatureAttackMultiplier).toBe(1.6);
expect(fx.reactionInstability).toBe(0.3);
expect(fx.environmentalDamage).toBeGreaterThan(0);
});
it('escalation clamps values correctly', () => {
const fx = getEscalationEffects(2.0); // over max
expect(fx.creatureSpeedMultiplier).toBe(1.5);
const fxNeg = getEscalationEffects(-1.0); // under min
expect(fxNeg.creatureSpeedMultiplier).toBe(1.0);
});
});
// ─── Crisis Damage ───────────────────────────────────────────────
describe('Crisis Player Damage', () => {
it('no damage below 30% progress', () => {
const crisis = createCrisisState(CHEMICAL_PLAGUE);
crisis.progress = 0.2;
expect(getCrisisPlayerDamage(crisis, 1000)).toBe(0);
});
it('damage increases with progress above 30%', () => {
const crisis = createCrisisState(CHEMICAL_PLAGUE);
crisis.progress = 0.5;
const dmg = getCrisisPlayerDamage(crisis, 1000);
expect(dmg).toBeGreaterThan(0);
});
it('damage scales with delta time', () => {
const crisis = createCrisisState(CHEMICAL_PLAGUE);
crisis.progress = 0.8;
const dmg1 = getCrisisPlayerDamage(crisis, 1000);
const dmg2 = getCrisisPlayerDamage(crisis, 2000);
expect(dmg2).toBeCloseTo(dmg1 * 2, 1);
});
it('no damage when resolved', () => {
const crisis = createCrisisState(CHEMICAL_PLAGUE);
crisis.progress = 0.8;
crisis.resolved = true;
expect(getCrisisPlayerDamage(crisis, 1000)).toBe(0);
});
});
describe('Crisis Visual Tint', () => {
it('returns no tint at 0 progress', () => {
const crisis = createCrisisState(CHEMICAL_PLAGUE);
crisis.progress = 0;
const tint = getCrisisTint(crisis);
expect(tint.alpha).toBe(0);
});
it('returns tint at high progress', () => {
const crisis = createCrisisState(CHEMICAL_PLAGUE);
crisis.progress = 0.8;
const tint = getCrisisTint(crisis);
expect(tint.alpha).toBeGreaterThan(0);
expect(tint.color).toBeDefined();
});
it('returns no tint when resolved', () => {
const crisis = createCrisisState(CHEMICAL_PLAGUE);
crisis.progress = 0.8;
crisis.resolved = true;
const tint = getCrisisTint(crisis);
expect(tint.alpha).toBe(0);
});
});
// ─── Escalation-Phase Integration ────────────────────────────────
describe('Escalation Integration', () => {
let state: RunState;
beforeEach(() => {
state = createRunState(1, 'alchemist');
state.phase = RunPhase.Escalation;
});
it('escalation grows steadily over time', () => {
// Simulate 30 seconds
for (let i = 0; i < 30; i++) {
updateEscalation(state, 1000);
}
expect(state.escalation).toBeCloseTo(ESCALATION_RATE * 30, 2);
});
it('effects scale linearly with escalation', () => {
updateEscalation(state, 100_000); // ~100 seconds
const fx = getEscalationEffects(state.escalation);
expect(fx.creatureSpeedMultiplier).toBeGreaterThan(1.0);
expect(fx.creatureAggroRange).toBeGreaterThan(1.0);
});
it('full escalation cycle reaches crisis threshold', () => {
// Simulate until threshold
for (let i = 0; i < 200; i++) {
updateEscalation(state, 1000);
}
expect(state.escalation).toBeGreaterThanOrEqual(CHEMICAL_PLAGUE.triggerThreshold);
});
});