import { describe, it, expect } from 'vitest' import { ADDITIVE_MONSTER_TOKENS as SCENE_TOKENS } from '../src/scene/act-scene.ts' import { ADDITIVE_MONSTER_TOKENS } from '../src/game/monster-art.ts' import { executeMonsterRenderLoop, MockRenderer, type MonsterSimInput, type AnimatorSimInput, } from '../scripts/verify-monster-dispatch.ts' import type { AtlasFrame } from '../src/render/atlas.ts' import type { AtlasHandle } from '../src/render/renderer.ts' import type { LoadedMonsterArt } from '../src/game/monster-art.ts' function makeFrame(w: number, h: number, ax?: number, ay?: number): AtlasFrame { return { x: 0, y: 0, width: w, height: h, ...(ax !== undefined ? { anchorX: ax } : {}), ...(ay !== undefined ? { anchorY: ay } : {}), } } function makeArt(params: { token: string blendMode?: 'normal' | 'additive' | undefined hasGlow?: boolean glowFrameSize?: [number, number] directions?: number }): LoadedMonsterArt { const dirs = params.directions ?? 8 const totalGroups = 64 const groups: AtlasFrame[][] = [] for (let d = 0; d < totalGroups; d++) { groups.push([makeFrame(64, 64, -32, -60)]) } let glowGroups: AtlasFrame[][] | undefined = undefined if (params.hasGlow) { glowGroups = [] const [gw, gh] = params.glowFrameSize ?? [64, 64] for (let d = 0; d < totalGroups; d++) { glowGroups.push([makeFrame(gw, gh, -32, -60)]) } } return { token: params.token, handle: 88 as unknown as AtlasHandle, groups, ...(glowGroups !== undefined ? { glowGroups } : {}), ...(params.blendMode !== undefined ? { blendMode: params.blendMode } : {}), directions: dirs, walkOffset: 0, standOffset: dirs, walkFrames: 1, standFrames: 1, layerFailures: [], } } describe('Monster Rendering Dispatch in act-scene.ts (Milestone 2 / R2)', () => { it('re-exports ADDITIVE_MONSTER_TOKENS from monster-art.ts matching ground truth', () => { expect(SCENE_TOKENS).toBe(ADDITIVE_MONSTER_TOKENS) const expected = ['WR', 'WW', 'FR', 'K9', '17', '46', 'FI', 'X3', 'X4', 'XW'] for (const t of expected) { expect(SCENE_TOKENS.has(t)).toBe(true) } expect(SCENE_TOKENS.size).toBe(expected.length) }) it('Case 1: Pure additive monster (WR, WW) renders single draw call with blendMode: "additive"', () => { const renderer = new MockRenderer() for (const token of ['WR', 'WW', 'wr', 'ww']) { renderer.clear() const art = makeArt({ token, blendMode: 'additive' }) const artMap = new Map([['mon', art]]) const monster: MonsterSimInput = { index: 1, state: 'chase', corpseTicks: 0, facing: 0, x: 400, y: 500, stats: { id: 'mon' }, } executeMonsterRenderLoop([monster], artMap, new Map(), 0, renderer) expect(renderer.drawCalls).toHaveLength(1) const call = renderer.drawCalls[0]! expect(call.options.blendMode).toBe('additive') expect(call.options.atlas).toBe(art.handle) expect(call.drawX).toBe(400 - 32) expect(call.drawY).toBe(500 - 60) } }) it('Case 2: Composite monster (FR) renders base pass (normal) + glow pass (additive) at identical coordinates', () => { const renderer = new MockRenderer() const art = makeArt({ token: 'FR', blendMode: 'normal', hasGlow: true, glowFrameSize: [64, 64] }) const artMap = new Map([['fingermage', art]]) const monster: MonsterSimInput = { index: 2, state: 'chase', corpseTicks: 0, facing: 2, x: 700, y: 800, stats: { id: 'fingermage' }, } executeMonsterRenderLoop([monster], artMap, new Map(), 0, renderer) expect(renderer.drawCalls).toHaveLength(2) const [baseCall, glowCall] = renderer.drawCalls // Base pass expect(baseCall!.options.blendMode).toBe('normal') expect(baseCall!.options.atlas).toBe(art.handle) // Glow pass expect(glowCall!.options.blendMode).toBe('additive') expect(glowCall!.options.atlas).toBe(art.handle) // Zero-drift alignment expect(baseCall!.drawX).toBe(glowCall!.drawX) expect(baseCall!.drawY).toBe(glowCall!.drawY) expect(baseCall!.drawX).toBe(700 - 32) expect(baseCall!.drawY).toBe(800 - 60) }) it('Case 3a: Legacy unbaked packs with tokens in ADDITIVE_MONSTER_TOKENS fall back to blendMode: "additive"', () => { const renderer = new MockRenderer() const tokens = ['WR', 'WW', 'FR', 'K9', '17', '46', 'FI', 'X3', 'X4', 'XW'] for (const token of tokens) { for (const t of [token, token.toLowerCase()]) { renderer.clear() const art = makeArt({ token: t, blendMode: undefined, hasGlow: false }) const artMap = new Map([['testmon', art]]) const monster: MonsterSimInput = { index: 3, state: 'idle', corpseTicks: 0, facing: 0, x: 100, y: 200, stats: { id: 'testmon' }, } executeMonsterRenderLoop([monster], artMap, new Map(), 0, renderer) expect(renderer.drawCalls).toHaveLength(1) expect(renderer.drawCalls[0]!.options.blendMode).toBe('additive') } } }) it('Case 3b: Legacy unbaked packs with regular monsters default to blendMode: "normal"', () => { const renderer = new MockRenderer() const tokens = ['FA', 'ZM', 'GO', 'DK', 'SK', 'CR', 'MS'] for (const token of tokens) { renderer.clear() const art = makeArt({ token, blendMode: undefined, hasGlow: false }) const artMap = new Map([['regmon', art]]) const monster: MonsterSimInput = { index: 4, state: 'idle', corpseTicks: 0, facing: 0, x: 100, y: 200, stats: { id: 'regmon' }, } executeMonsterRenderLoop([monster], artMap, new Map(), 0, renderer) expect(renderer.drawCalls).toHaveLength(1) expect(renderer.drawCalls[0]!.options.blendMode).toBe('normal') } }) it('Case 4: Corpse frame with 1x1 empty dimensions cleanly skips glow draw call', () => { const renderer = new MockRenderer() const art = makeArt({ token: 'FR', blendMode: 'normal', hasGlow: true, glowFrameSize: [1, 1] }) const artMap = new Map([['corpse_fr', art]]) const monster: MonsterSimInput = { index: 5, state: 'dead', corpseTicks: 100, facing: 0, x: 300, y: 400, stats: { id: 'corpse_fr' }, } executeMonsterRenderLoop([monster], artMap, new Map(), 0, renderer) expect(renderer.drawCalls).toHaveLength(1) expect(renderer.drawCalls[0]!.options.blendMode).toBe('normal') }) it('Case 5: Dead monster with corpseTicks <= 0 is completely skipped', () => { const renderer = new MockRenderer() const art = makeArt({ token: 'FR', blendMode: 'normal', hasGlow: true }) const artMap = new Map([['dead_fr', art]]) const monster: MonsterSimInput = { index: 6, state: 'dead', corpseTicks: 0, facing: 0, x: 300, y: 400, stats: { id: 'dead_fr' }, } executeMonsterRenderLoop([monster], artMap, new Map(), 0, renderer) expect(renderer.drawCalls).toHaveLength(0) }) it('Case 6: Chilled monster tint applies to base body without polluting additive glow pass', () => { const renderer = new MockRenderer() const art = makeArt({ token: 'FR', blendMode: 'normal', hasGlow: true, glowFrameSize: [64, 64] }) const artMap = new Map([['chilled_fr', art]]) const monster: MonsterSimInput = { index: 7, state: 'chase', corpseTicks: 0, facing: 0, x: 200, y: 300, stats: { id: 'chilled_fr' }, chillTicks: 50, } executeMonsterRenderLoop([monster], artMap, new Map(), 0, renderer) expect(renderer.drawCalls).toHaveLength(2) const [baseCall, glowCall] = renderer.drawCalls expect(baseCall!.options.paletteRow).toBe(1) expect(baseCall!.options.tint).toBeDefined() expect(baseCall!.options.blendMode).toBe('normal') expect(glowCall!.options.paletteRow).toBeUndefined() expect(glowCall!.options.tint).toBeUndefined() expect(glowCall!.options.blendMode).toBe('additive') }) it('Case 7: Animator clip calculation correctly indexes group and frame', () => { const renderer = new MockRenderer() const art = makeArt({ token: 'FR', blendMode: 'normal', hasGlow: true }) const artMap = new Map([['clip_fr', art]]) const animators = new Map([ [8, { clip: { group: 16, directions: 8, frames: 10 }, frameIndex: 3 }], ]) const monster: MonsterSimInput = { index: 8, state: 'attack', corpseTicks: 0, facing: 4, x: 100, y: 100, stats: { id: 'clip_fr' }, } executeMonsterRenderLoop([monster], artMap, animators, 0, renderer) expect(renderer.drawCalls).toHaveLength(2) expect(renderer.drawCalls[0]!.options.blendMode).toBe('normal') expect(renderer.drawCalls[1]!.options.blendMode).toBe('additive') }) })