namespace LivingVillage.Desktop.Tests open System open LivingVillage.Headless open System.IO open Microsoft.Xna.Framework open Microsoft.VisualStudio.TestTools.UnitTesting open LivingVillage.Kernel open LivingVillage.Kernel.Sim open LivingVillage.Desktop open LivingVillage.Desktop.ChineseText open LivingVillage.Desktop.CjkGlyphAtlas open LivingVillage.Desktop.VillageArt open LivingVillage.Desktop.VillagePresentation [] type PrototypeTests () = [] member _.SceneContainsTheJiangnanPrototypeAndReachableHomeDoor () = let first = createScene () let second = createScene () let requiredProps = [ WhiteWallDarkTileHome HomeDoor RiverChannel RiverBank StoneBridge StonePaving Bamboo VegetableGarden ] Assert.AreEqual(first, second) Assert.AreEqual(scene, first) Assert.IsTrue(first.Homes.Length > 0, "The prototype needs at least one home.") Assert.IsTrue( requiredProps |> List.forall (fun prop -> first.Elements |> List.exists (fun element -> element.Prop = prop)), "The scene is missing one or more required Jiangnan elements.") let home = first.Homes |> List.head Assert.IsTrue(isNearHomeDoor first.InitialPlayerPosition home) [] member _.HomeEntryAndExitExposeExplicitSuccessAndFailureBranches () = let scene = createScene () let home = scene.Homes |> List.head let entered = enterHome scene.InitialPlayerPosition Outside match entered with | Ok (Inside enteredHome) -> Assert.AreEqual(home.Id, enteredHome) | Ok Outside -> Assert.Fail("Entering a home must produce Inside state.") | Error failure -> Assert.Fail(sprintf "Expected entry to succeed, got %A." failure) match entered with | Ok inside -> match enterHome scene.InitialPlayerPosition inside with | Error (AlreadyInsideHome homeId) -> Assert.AreEqual(home.Id, homeId) | other -> Assert.Fail(sprintf "Expected AlreadyInsideHome, got %A." other) | Error _ -> Assert.Fail("The first entry must succeed before checking the duplicate entry.") match exitHome (Inside home.Id) with | Ok Outside -> () | other -> Assert.Fail(sprintf "Expected exit to succeed, got %A." other) match exitHome Outside with | Error NotInsideHome -> () | other -> Assert.Fail(sprintf "Expected NotInsideHome, got %A." other) match exitHome (Inside(HomeId 999)) with | Error(HomeNotFound(HomeId 999)) -> () | other -> Assert.Fail(sprintf "Expected HomeNotFound, got %A." other) let farAway : TilePosition = { X = scene.InitialPlayerPosition.X + 20 Y = scene.InitialPlayerPosition.Y + 20 } match enterHome farAway Outside with | Error NotNearHomeDoor -> () | other -> Assert.Fail(sprintf "Expected NotNearHomeDoor, got %A." other) [] member _.FourWayInputMapsToFourDirections () = Assert.AreEqual(Some North, directionForInput UpInput) Assert.AreEqual(Some South, directionForInput DownInput) Assert.AreEqual(Some West, directionForInput LeftInput) Assert.AreEqual(Some East, directionForInput RightInput) Assert.AreEqual(None, directionForInput NoInput) [] member _.AnimationFrameChangesWithSimulationTick () = let first = animationFrame (SimulationTick 0L) let second = animationFrame (SimulationTick 4L) let nextCycle = animationFrame (SimulationTick 8L) Assert.AreNotEqual(first, second) Assert.AreEqual(first, nextCycle) [] member _.NpcIdProducesStableAndDistinctCharacterStyles () = let first = characterStyle (NpcId 0) let sameNpc = characterStyle (NpcId 0) let second = characterStyle (NpcId 1) Assert.AreEqual(first, sameNpc) Assert.AreNotEqual(first, second) [] member _.StageOneCopyProvidesChineseInteractionLabels () = Assert.AreEqual("互动", stageOneCopy.Interact) Assert.AreEqual("需求", stageOneCopy.Needs) Assert.AreEqual("交易", stageOneCopy.Trade) Assert.AreEqual("进入民居", stageOneCopy.EnterHome) [] member _.MenuControlsAndSettingsUseChineseDisplayCopy () = let state = MenuState.create SimulationControl.initial false let itemLabels = MenuState.items state |> List.map MenuState.itemLabel let controlLabels = MenuState.controlRows |> List.map (fun row -> row.Action) let settingsLabels = MenuState.settings |> List.map MenuState.settingsLabel Assert.IsTrue(itemLabels |> List.contains "开始") Assert.IsTrue(itemLabels |> List.contains "读档") Assert.IsTrue(itemLabels |> List.contains "退出") Assert.AreEqual("设置", MenuState.itemLabel SettingsItem) Assert.AreEqual("操作说明", MenuState.itemLabel ControlsItem) Assert.AreEqual( [ "移动" "互动" "对话" "观察" "需求" "年鉴" "关系" "暂停" "速度" "保存" "读取" "菜单或关闭" "选择" "确认" ], controlLabels) Assert.AreEqual([ "1倍速"; "2倍速"; "5倍速"; "暂停" ], settingsLabels) [] member _.InteractionPanelsUseChineseLabelsAndExplicitTradeResults () = let world = Sim.initialWorldN 42UL 4 let nearby = { world with Avatar = { world.Avatar with Pos = world.Npcs.[0].Pos } } let openedWorld, opened = M5Interaction.apply Interact nearby M5Interaction.initial let title = M5Interaction.titleText opened let needsWorld, needsView = M5Interaction.apply ToggleNeeds openedWorld M5Interaction.initial let needsText = String.concat "|" (M5Interaction.panelLines needsWorld needsView) let _, chronicleView = M5Interaction.apply ShowChronicle nearby M5Interaction.initial let chronicleText = String.concat "|" (M5Interaction.panelLines nearby chronicleView) let request = { Buyer = NpcId 0 Seller = NpcId 1 Item = Food Quantity = 1 } let quote = Sim.quotePrice request nearby let successText = M5Interaction.tradeResultText request quote (Sim.trade request nearby) let failureRequest = { request with Quantity = 0 } let failureText = M5Interaction.tradeResultText failureRequest (Sim.quotePrice failureRequest nearby) (Sim.trade failureRequest nearby) Assert.IsTrue(title.Contains("互动")) Assert.IsTrue(title.Contains("闲聊")) Assert.IsFalse(title.Contains("SMALLTALK")) Assert.IsTrue(needsText.Contains("需求")) Assert.IsTrue(needsText.Contains("饥饿")) Assert.IsTrue(chronicleText.Contains("年鉴")) Assert.IsTrue(chronicleText.Contains("暂无记录")) Assert.IsTrue(successText.Contains("交易成功")) Assert.IsTrue(successText.Contains("报价")) Assert.IsTrue(failureText.Contains("交易失败")) Assert.IsTrue(failureText.Contains("数量")) [] member _.DayNightAndSpeedUseChineseDisplayCopy () = Assert.AreEqual("白天", M6Presentation.dayNightLabel Day) Assert.AreEqual("夜晚", M6Presentation.dayNightLabel Night) Assert.AreEqual("暂停", M6Presentation.clockLabel { Paused = true; Speed = OneX }) Assert.AreEqual("1倍速", M6Presentation.clockLabel { Paused = false; Speed = OneX }) Assert.AreEqual("2倍速", M6Presentation.clockLabel { Paused = false; Speed = TwoX }) Assert.AreEqual("5倍速", M6Presentation.clockLabel { Paused = false; Speed = FiveX }) [] member _.JiangnanArtPlanIsDeterministicAndContainsRequiredElements () = let first = samplePlan () let second = samplePlan () let requiredProps = [ VillageArt.WhiteWallDarkTileHouse VillageArt.StoneBridge VillageArt.Bamboo VillageArt.VegetableGarden ] Assert.AreEqual(first, second) Assert.IsTrue( requiredProps |> List.forall (fun prop -> first.Props |> List.exists (fun sprite -> sprite.Kind = prop)), "The Jiangnan plan is missing one or more required props.") Assert.IsTrue(first.Tiles |> List.exists (fun tile -> tile.Kind = (VillageArt.RiverWater))) Assert.IsTrue(first.Tiles |> List.exists (fun tile -> tile.Kind = (VillageArt.StonePaving))) [] member _.PauseHelpOpensFromPlayingAndRestoresSimulationControl () = let original = { Paused = false; Speed = TwoX } let state = { MenuState.create original true with Page = Playing } let helpState, pausedControl = MenuState.enterPauseHelp original state Assert.AreEqual(Controls, helpState.Page) Assert.AreEqual(Playing, helpState.ReturnPage) Assert.IsTrue(pausedControl.Paused) Assert.AreEqual(TwoX, pausedControl.Speed) let backState, restoredControl = MenuState.exitPauseHelp original helpState Assert.AreEqual(Playing, backState.Page) Assert.IsFalse(restoredControl.Paused) Assert.AreEqual(original, restoredControl) [] member _.ControlsPageKeyColumnIsUniformAndNeverOverprintsActions () = let layout = TitleScreen.layout 1280 720 let rows = MenuState.controlRows // Every row: key-name rectangle and action rectangle must be disjoint. for rowIndex in 0 .. rows.Length - 1 do let keyRect, actionRect = TitleScreen.controlRowRects layout rowIndex Assert.IsTrue(TitleScreen.rectsDisjoint keyRect actionRect, sprintf "row %d key overlaps action" rowIndex) // The key column is uniform: every row shares the same x and width. let keyRects = [ 0 .. rows.Length - 1 ] |> List.map (fun index -> TitleScreen.controlRowRects layout index |> fst) Assert.AreEqual(1, keyRects |> List.map (fun rect -> rect.X) |> List.distinct |> List.length) Assert.AreEqual(1, keyRects |> List.map (fun rect -> rect.Width) |> List.distinct |> List.length) // The key column fits the longest key name (latin glyph: 6px * scale 2 per char). let longestKey = rows |> List.maxBy (fun row -> row.Keys.Length) Assert.IsTrue(layout.ControlsKeyWidth >= longestKey.Keys.Length * 12, sprintf "key width %d too small for %s" layout.ControlsKeyWidth longestKey.Keys) // The controls panel holds every row plus the footer inside the viewport. let panelBottom = layout.ControlsPanelY + 60 + rows.Length * layout.ControlsRowHeight + 48 Assert.IsTrue(panelBottom <= 720, sprintf "controls panel bottom %d overflows" panelBottom) [] member _.CornerStatusShowsDayMoneyAndEnergyFromRealData () = let world = Sim.initialWorldN 42UL 0 let lines = M5Interaction.cornerStatusLines world Assert.AreEqual(2, lines.Length) Assert.IsTrue(lines.[0].StartsWith("天数 "), sprintf "day line: %s" lines.[0]) Assert.IsTrue(lines.[1].StartsWith("金钱 "), sprintf "money line: %s" lines.[1]) Assert.IsTrue(lines.[1].Contains("精力")) let nextDay = { world with Tick = world.Tick + Sim.ticksPerDay } Assert.AreNotEqual(lines.[0], M5Interaction.cornerStatusLines nextDay |> List.item 0) let richWorld = { world with Avatar = { world.Avatar with Mind = { world.Avatar.Mind with Needs = { world.Avatar.Mind.Needs with Money = 88.0f; Energy = 76.0f } } } } let richLines = M5Interaction.cornerStatusLines richWorld Assert.IsTrue(richLines.[1].Contains("88"), sprintf "money value missing: %s" richLines.[1]) Assert.IsTrue(richLines.[1].Contains("76"), sprintf "energy value missing: %s" richLines.[1]) Assert.IsTrue(ChineseText.requiredUiLabels |> List.contains "天数") Assert.IsTrue(ChineseText.requiredUiLabels |> List.contains "金钱") Assert.IsTrue(ChineseText.requiredUiLabels |> List.contains "精力") [] member _.TitleScreenSelectionCursorAndLabelNeverOverprint () = let layout = TitleScreen.layout 1280 720 // Heading must sit fully above the first menu row (no same-line overprint). Assert.IsTrue(layout.FirstItemY - layout.HeadingY >= 32, sprintf "heading clearance %d" (layout.FirstItemY - layout.HeadingY)) // Every menu row: cursor rectangle and label rectangle must be disjoint. for rowIndex in 0 .. 5 do let cursor, label = TitleScreen.selectionRects layout rowIndex Assert.IsTrue(TitleScreen.rectsDisjoint cursor label, sprintf "row %d cursor overlaps label" rowIndex) Assert.AreEqual(cursor.Y, label.Y) // Cursor column leaves real clearance: label starts beyond cursor + margin. Assert.IsTrue(layout.TextX >= layout.CursorX + layout.CursorWidth) [] member _.TitleScreenLayoutIsDeterministicAndLayered () = let first = TitleScreen.layout 1280 720 let second = TitleScreen.layout 1280 720 Assert.AreEqual(first, second) // Title: existing CJK atlas text, large scale, centered headline. Assert.AreEqual("江南水乡", first.TitleText) Assert.AreEqual(4, first.TitleScale) // Background must be a layered gradient, never a flat fullscreen block. Assert.IsTrue(first.SkyBands.Length >= 4) Assert.AreEqual(first.SkyBands.Length, first.SkyBands |> List.distinct |> List.length) // Skyline reuses the Jiangnan roof tiles (ridge=8, eave=9) across the full width. let roofSlots = TitleScreen.roofSlots Assert.AreEqual(8, fst roofSlots) Assert.AreEqual(9, snd roofSlots) Assert.AreEqual(1280 / 32, first.RoofTileSlots.Length) Assert.IsTrue(first.RoofTileSlots |> List.forall (fun slot -> slot = 8 || slot = 9)) Assert.IsTrue(first.RoofTileSlots |> List.contains 8) Assert.IsTrue(first.RoofTileSlots |> List.contains 9) // Water band reuses the water tile slot and anchors the lantern row. Assert.AreEqual(1, TitleScreen.waterSlot) Assert.AreEqual(2, first.WaterRows.Length) Assert.IsTrue(first.Lanterns.Length >= 3) let lanternColor, lanternHalo = TitleScreen.lanternColors Assert.AreEqual(212, lanternColor.R) Assert.AreEqual(92, lanternColor.G) Assert.AreEqual(74, lanternColor.B) Assert.IsTrue(lanternHalo.R > lanternColor.R && lanternHalo.G > lanternColor.G) // Menu items form one vertical column below the title. Assert.IsTrue(first.MenuTopY > first.TitleY) Assert.IsTrue(first.MenuLineHeight > 0) Assert.IsTrue(first.PanelX + first.PanelWidth <= 1280) Assert.IsTrue(first.PanelY + first.PanelHeight <= 720) [] member _.NpcVariantsAreStableAndAnimationCoversFourDirectionsAndSixFrames () = let sameNpc = npcVisualVariant (NpcId 7) let repeatedNpc = npcVisualVariant (NpcId 7) let otherNpc = npcVisualVariant (NpcId 8) let directions = [ NorthFacing SouthFacing WestFacing EastFacing ] let sprites = directions |> List.map (fun direction -> npcSpriteSpec (NpcId 0) direction true 4L) Assert.AreEqual(sameNpc, repeatedNpc) Assert.AreNotEqual(sameNpc, otherNpc) Assert.AreEqual(4, sprites |> List.map (fun sprite -> sprite.Direction) |> List.distinct |> List.length) Assert.IsTrue(sprites |> List.forall (fun sprite -> sprite.Frame = WalkFrame FrameTwo)) Assert.AreEqual(FrameOne, (VillageArt.animationFrame 0L)) Assert.AreEqual(FrameTwo, (VillageArt.animationFrame 4L)) Assert.AreEqual(FrameThree, (VillageArt.animationFrame 8L)) Assert.AreEqual(FrameFour, (VillageArt.animationFrame 12L)) Assert.AreEqual(FrameFive, (VillageArt.animationFrame 16L)) Assert.AreEqual(FrameSix, (VillageArt.animationFrame 20L)) Assert.AreEqual(FrameOne, (VillageArt.animationFrame 24L)) [] member _.VillagersUseFourDistinctVariantsDistinctFromTheAvatar () = let npcVariants = [ 0 .. 63 ] |> List.map (fun id -> npcVisualVariant (NpcId id)) |> Set.ofList Assert.AreEqual(4, npcVariants.Count) Assert.IsFalse(npcVariants.Contains StrawHat) Assert.AreEqual(StrawHat, (avatarSpriteSpec SouthFacing true 0L).Variant) let variantXes = npcVariants |> Set.toList |> List.map (fun variant -> let rect = characterSourceRectangle { Variant = variant; Direction = SouthFacing; Frame = WalkFrame FrameOne } rect.X) |> Set.ofList Assert.AreEqual(4, variantXes.Count) Assert.AreEqual(5120, VillageArt.characterAtlasWidth) [] member _.WalkAnimationCoversSixFramesDeterministically () = // 24-tick cycle, new frame every 4 sim ticks, no wall-clock dependency. let frames = [ 0L; 4L; 8L; 12L; 16L; 20L ] |> List.map VillageArt.animationFrame Assert.AreEqual([ FrameOne; FrameTwo; FrameThree; FrameFour; FrameFive; FrameSix ], frames) let cycleFrames = [ 0L; 4L; 8L; 12L; 16L; 20L ] |> List.map VillageArt.animationFrame |> List.distinct Assert.AreEqual([ FrameOne; FrameTwo; FrameThree; FrameFour; FrameFive; FrameSix ], cycleFrames) // Every frame maps to a distinct atlas cell column across the five variants. let perFrameX = [ FrameOne; FrameTwo; FrameThree; FrameFour; FrameFive; FrameSix ] |> List.map (fun frame -> let rect = characterSourceRectangle { Variant = IndigoRobe; Direction = NorthFacing; Frame = WalkFrame frame } rect.X / 32) Assert.AreEqual([ 0; 1; 2; 3; 4; 5 ], perFrameX) // Idle breath poses occupy the two cells after the walk cycle for each (variant, direction). let idleXes = [ IdleOne; IdleTwo ] |> List.map (fun frame -> let rect = characterSourceRectangle { Variant = IndigoRobe; Direction = NorthFacing; Frame = IdleFrame frame } rect.X / 32) Assert.AreEqual([ 6; 7 ], idleXes) [] member _.ChineseCopyProvidesFormalSceneTimeMenuAndStatusLabels () = let labels = [ sceneLabel VillageScene sceneLabel Waterway sceneLabel WhiteWallHome sceneLabel StoneBridgeScene sceneLabel ChineseText.BambooGrove sceneLabel VegetableGardenScene dayNightLabel DayTime dayNightLabel NightTime menuHeading MainMenuHeading menuHeading SettingsHeading menuHeading ControlsHeading statusHeading StatusHeading statusHeading ReadyStatus statusHeading PausedStatus ] Assert.IsTrue(labels |> List.forall (fun label -> not (String.IsNullOrWhiteSpace label))) Assert.IsTrue(labels |> List.forall (fun label -> not (label.Contains("?")))) Assert.AreEqual("江南水乡", copy.SceneTitle) Assert.AreEqual("准备就绪", copy.Ready) [] member _.ChineseTextWrapsAndUsesAnExplicitMissingGlyphPolicy () = let supported = Set.ofList [ '江'; '南'; '水'; '乡' ] let layout = layoutText 4 supported "江南水乡村" Assert.AreEqual([ "江南水乡"; "□" ], layout.Lines) Assert.AreEqual([ '村' ], layout.MissingGlyphs) Assert.IsFalse(String.concat "" layout.Lines |> fun text -> text.Contains("?")) [] member _.DesktopPixelPositionUsesExplicitSceneTileMapping () = let doorPixel : PixelPosition = { X = float32 (31 * Sim.tilePixels + Sim.tilePixels / 2) Y = float32 (23 * Sim.tilePixels + Sim.tilePixels / 2) } let expectedSceneTile : TilePosition = { X = 6; Y = 5 } let expectedWorldTile : TilePosition = { X = 31; Y = 23 } Assert.AreEqual(expectedSceneTile, sceneTileFromPixel doorPixel) Assert.AreEqual(expectedWorldTile, worldTileForSceneTile expectedSceneTile) [] member _.HomeInteractionPrioritizesEntryExitBeforeNpcDialogue () = let doorPixel : PixelPosition = { X = float32 (31 * Sim.tilePixels + Sim.tilePixels / 2) Y = float32 (23 * Sim.tilePixels + Sim.tilePixels / 2) } let farPixel = { X = 0.0f; Y = 0.0f } Assert.AreEqual(EnterHomeAction(HomeId 1), homeInputAction doorPixel Outside) Assert.AreEqual(ExitHomeAction(HomeId 1), homeInputAction doorPixel (Inside(HomeId 1))) Assert.AreEqual(DialogueAction, homeInputAction farPixel Outside) Assert.AreEqual(Outside, resetHomeMode ()) [] member _.JiangnanDesktopRenderPlanIsDeterministicAndHasInteriorElements () = let first = VillageArt.sampleRenderPlan () let second = VillageArt.sampleRenderPlan () Assert.AreEqual(first, second) let expectedOrigin : TilePosition = { X = 25; Y = 18 } Assert.AreEqual(expectedOrigin, first.Origin) Assert.IsTrue(first.Grounds |> List.exists (fun (_, kind) -> kind = VillageArt.Water)) Assert.IsTrue(first.Grounds |> List.exists (fun (_, kind) -> kind = VillageArt.StonePath)) Assert.IsTrue(first.Props |> List.exists (fun prop -> prop.Kind = VillageArt.Bridge)) // P64 补入椅/凳/床尾凳/壶 4 件家具 → 内景元素 4 → 8。 Assert.AreEqual(8, first.Interior.Elements.Length) [] member _.HouseFacadeCoversRoofEaveAndWalledDoorRow () = let plan = VillageArt.sampleRenderPlan () let footprint = VillageArt.planFootprint plan |> Set.ofList let door = scene.Homes |> List.head |> fun home -> VillageArt.worldTile plan home.Door // The enterable home must present a full facade: ridge, eave, and a walled row with the door. for dx in -2 .. 1 do Assert.IsTrue(footprint.Contains({ X = door.X + dx; Y = door.Y - 2 } : TilePosition), sprintf "missing ridge tile dx=%d" dx) Assert.IsTrue(footprint.Contains({ X = door.X + dx; Y = door.Y - 1 } : TilePosition), sprintf "missing eave tile dx=%d" dx) Assert.IsTrue(footprint.Contains({ X = door.X + dx; Y = door.Y } : TilePosition), sprintf "missing wall tile dx=%d" dx) // 12 house + 3 bridge + 3 bamboo + 2 garden + 2x2 willow + 2 shrub + 2 flowers + 2 reeds // + 1 bench + 2 lanterns + 2 stone lanterns + 1 water vat + 1 market stall (P17) // + P64 上半部补种 4 菜畦(2格)+2 竹丛(3格)+2 灌木+2 花丛 = 37 + 18 = 55。 Assert.AreEqual(55, footprint.Count) [] member _.VegetationAndRiversidePropsDecorateTheScene () = let plan = VillageArt.sampleRenderPlan () let kinds = plan.Props |> List.map (fun prop -> prop.Kind) let count kind = kinds |> List.filter (fun candidate -> candidate = kind) |> List.length let grounds = plan.Grounds |> List.map fst |> Set.ofList Assert.AreEqual(2, count VillageArt.WillowTree) Assert.IsTrue(count VillageArt.ShrubPatch >= 2) Assert.IsTrue(count VillageArt.FlowerBush >= 2) Assert.AreEqual(2, count VillageArt.ReedCluster) Assert.AreEqual(1, count VillageArt.StoneBench) Assert.AreEqual(2, count VillageArt.RiverLantern) Assert.AreEqual(2, count VillageArt.StoneLantern) Assert.AreEqual(1, count VillageArt.WaterVat) Assert.AreEqual(1, count VillageArt.MarketStall) // Reeds must root in water; lanterns and P17 street details stand on the paved bank path. for prop in plan.Props do let tile = VillageArt.worldTile plan prop.Position match prop.Kind with | VillageArt.ReedCluster -> Assert.IsTrue(grounds.Contains(prop.Position), "reeds must sit on water tiles") | VillageArt.RiverLantern | VillageArt.StoneLantern | VillageArt.WaterVat | VillageArt.MarketStall -> Assert.IsTrue(plan.Grounds |> List.contains (prop.Position, VillageArt.StonePath), "street details must stand on the bank path") | _ -> () [] member _.WaterRippleFramesAreDeterministicAndThreePhase () = Assert.AreEqual(0, VillageArt.waterFrameTick 0L) Assert.AreEqual(0, VillageArt.waterFrameTick 31L) Assert.AreEqual(1, VillageArt.waterFrameTick 32L) Assert.AreEqual(1, VillageArt.waterFrameTick 63L) Assert.AreEqual(2, VillageArt.waterFrameTick 64L) Assert.AreEqual(2, VillageArt.waterFrameTick 95L) Assert.AreEqual(0, VillageArt.waterFrameTick 96L) Assert.AreEqual(VillageArt.waterFrameTick 100L, VillageArt.waterFrameTick 100L) [] member _.IdleBreathCycleIsDeterministicAndDistinctFromWalking () = // Two-pose breath cycle over a 48-tick window (one pose per 24 sim ticks). Assert.AreEqual(IdleOne, VillageArt.idleFrame 0L) Assert.AreEqual(IdleOne, VillageArt.idleFrame 23L) Assert.AreEqual(IdleTwo, VillageArt.idleFrame 24L) Assert.AreEqual(IdleOne, VillageArt.idleFrame 48L) Assert.AreEqual(VillageArt.idleFrame 77L, VillageArt.idleFrame 77L) // Standing still breathes; moving walks. Both stay pure functions of tick. Assert.AreEqual(IdleFrame IdleOne, VillageArt.characterFrame false 0L) Assert.AreEqual(IdleFrame IdleTwo, VillageArt.characterFrame false 24L) Assert.AreEqual(WalkFrame FrameOne, VillageArt.characterFrame true 0L) Assert.AreEqual(WalkFrame FrameTwo, VillageArt.characterFrame true 4L) Assert.AreEqual(WalkFrame FrameFive, VillageArt.characterFrame true 16L) Assert.AreEqual(WalkFrame FrameSix, VillageArt.characterFrame true 20L) Assert.AreEqual(WalkFrame FrameOne, VillageArt.characterFrame true 24L) Assert.AreEqual(VillageArt.characterFrame true 12L, VillageArt.characterFrame true 12L) [] member _.NpcTradesAndWalkRhythmsAreDistinctAndDeterministic () = // Three trades repeat by id and stay stable, with no randomness. Assert.AreEqual(VillageArt.Farmer, VillageArt.npcTrade (NpcId 0)) Assert.AreEqual(VillageArt.Peddler, VillageArt.npcTrade (NpcId 1)) Assert.AreEqual(VillageArt.Scholar, VillageArt.npcTrade (NpcId 2)) Assert.AreEqual(VillageArt.npcTrade (NpcId 9), VillageArt.npcTrade (NpcId 0)) // Distinct cadences: peddler shuffles fastest, scholar strolls slowest. let farmer = VillageArt.rhythmFor VillageArt.Farmer let peddler = VillageArt.rhythmFor VillageArt.Peddler let scholar = VillageArt.rhythmFor VillageArt.Scholar Assert.IsTrue(peddler.WalkWindow < farmer.WalkWindow && farmer.WalkWindow < scholar.WalkWindow) Assert.IsTrue(peddler.IdleWindow < farmer.IdleWindow && farmer.IdleWindow < scholar.IdleWindow) // Same tick, three trade-specific frames (at 12 ticks: Farmer IV, Peddler VI, Scholar V). let frameAt trade tick = VillageArt.animationFrameFor (VillageArt.rhythmFor trade) tick let frames = [ VillageArt.Farmer; VillageArt.Peddler; VillageArt.Scholar ] |> List.map (fun trade -> frameAt trade 12L) Assert.AreEqual([ FrameFour; FrameSix; FrameFive ], frames) Assert.AreEqual(VillageArt.rhythmFor VillageArt.Scholar, VillageArt.rhythmFor VillageArt.Scholar) // npcSpriteSpec now follows the NPC's own trade cadence. Assert.AreEqual(WalkFrame FrameFour, (VillageArt.npcSpriteSpec (NpcId 0) VillageArt.SouthFacing true 12L).Frame) Assert.AreEqual(WalkFrame FrameSix, (VillageArt.npcSpriteSpec (NpcId 1) VillageArt.SouthFacing true 12L).Frame) Assert.AreEqual(WalkFrame FrameFive, (VillageArt.npcSpriteSpec (NpcId 2) VillageArt.SouthFacing true 12L).Frame) [] member _.ChimneySmokeFramesAreDeterministicAndThreePhase () = Assert.AreEqual(0, VillageArt.smokeFrameTick 0L) Assert.AreEqual(0, VillageArt.smokeFrameTick 39L) Assert.AreEqual(1, VillageArt.smokeFrameTick 40L) Assert.AreEqual(2, VillageArt.smokeFrameTick 80L) Assert.AreEqual(0, VillageArt.smokeFrameTick 120L) Assert.AreEqual(VillageArt.smokeFrameTick 99L, VillageArt.smokeFrameTick 99L) [] member _.PremultiplyScalesEachChannelByItsOwnAlpha () = // The warm lantern core: rgb(255,226,168) at alpha 34 must become 34/30/22 so the // premultiplied SpriteBatch blend adds a dim warm value instead of full rgb. let core = VillageArt.premultiply 255 226 168 34 Assert.AreEqual(34uy, core.R) Assert.AreEqual(30uy, core.G) Assert.AreEqual(22uy, core.B) Assert.AreEqual(34uy, core.A) // General fixed-point behaviour (round half away from zero). let warm = VillageArt.premultiply 252 196 128 34 Assert.AreEqual(34uy, warm.R) Assert.AreEqual(26uy, warm.G) Assert.AreEqual(17uy, warm.B) Assert.AreEqual(34uy, warm.A) // Alpha 255 is the identity; alpha 0 zeroes the colour. Assert.AreEqual(Color(252, 196, 128, 255), VillageArt.premultiply 252 196 128 255) Assert.AreEqual(Color(0, 0, 0, 0), VillageArt.premultiply 252 196 128 0) // A premultiplied channel can never exceed its own alpha, which is what makes an // opaque white core impossible for any alpha < 250. for alpha in [ 0; 1; 34; 70; 128; 200; 249 ] do let c = VillageArt.premultiply 255 255 255 alpha Assert.IsTrue(int c.R <= int c.A && int c.G <= int c.A && int c.B <= int c.A) Assert.IsTrue(int (max c.R (max c.G c.B)) < 250) [] member _.WaterRipplePhaseIsDistributedPerTile () = // Neighbouring tiles must not share a phase in the same frame. let a = VillageArt.waterFrameTickAt 128L 10 20 let b = VillageArt.waterFrameTickAt 128L 11 20 let c = VillageArt.waterFrameTickAt 128L 10 21 Assert.AreNotEqual(a, b) Assert.AreNotEqual(a, c) // Pure and in range 0..2 across the map and across ticks. for tick in [ 0L; 31L; 32L; 40L; 64L; 127L; 200L ] do for x in 0 .. 7 do for y in 0 .. 7 do let phase = VillageArt.waterFrameTickAt tick x y Assert.IsTrue(phase >= 0 && phase <= 2) Assert.AreEqual(phase, VillageArt.waterFrameTickAt tick x y) // A horizontal strip of river tiles shows at least two distinct phases at once. let strip = [ 0 .. 11 ] |> List.map (fun x -> VillageArt.waterFrameTickAt 128L x 12) Assert.IsTrue(Set.ofList strip |> Set.count >= 2) [] member _.LanternGlowLayersArePremultipliedAndRadial () = let centerX, centerY, radius, rings, peakAlpha = 100, 100, 44, 6, 140 let layers = VillageArt.lanternGlowLayers centerX centerY radius rings 255 214 150 peakAlpha Assert.IsTrue(layers.Length > 0, "the glow must emit at least one layer") Assert.AreEqual<(Rectangle * Color) list>(layers, VillageArt.lanternGlowLayers centerX centerY radius rings 255 214 150 peakAlpha) for (rect, color) in layers do Assert.AreEqual(1, rect.Height) Assert.IsTrue(rect.Width >= 1) Assert.IsTrue(int color.R <= int color.A && int color.G <= int color.A && int color.B <= int color.A) Assert.IsTrue(int (max color.R (max color.G color.B)) < 250, "no ring may be near-white") Assert.IsTrue(rect.X >= centerX - radius && rect.X + rect.Width <= centerX + radius + 1) Assert.IsTrue(rect.Y >= centerY - radius && rect.Y <= centerY + radius) let covers px py = layers |> List.exists (fun (rect, _) -> rect.X <= px && px < rect.X + rect.Width && rect.Y <= py && py < rect.Y + rect.Height) Assert.IsTrue(covers centerX centerY, "the core must be covered") Assert.IsFalse(covers (centerX + radius) (centerY + radius), "a square corner must stay dark: the glow is circular") Assert.IsFalse(covers (centerX - radius - 5) centerY) [] member _.LanternGlowIsRadialWithoutSquareCorners () = // P26 复核:光晕为同心圆叠加,逐像素累积 alpha;边缘/四角不得出现方斑。 let centerX, centerY, radius, rings, peakAlpha = 80, 80, 40, 6, 140 let layers = VillageArt.lanternGlowLayers centerX centerY radius rings 255 214 150 peakAlpha let size = radius * 2 + 3 let origin = centerX - radius - 1 let alpha = Array2D.zeroCreate size size for (rect, color) in layers do for y in rect.Y .. rect.Y + rect.Height - 1 do for x in rect.X .. rect.X + rect.Width - 1 do alpha.[y - origin, x - origin] <- alpha.[y - origin, x - origin] + int color.A let center = alpha.[radius + 1, radius + 1] Assert.IsTrue(center > 0, "the core must be lit") let corner = [ (0, 0); (size - 1, 0); (0, size - 1); (size - 1, size - 1) ] for (cx, cy) in corner do Assert.IsTrue(alpha.[cy, cx] = 0, "glow corner must stay empty: no square patch") // Radial falloff along the centre row: alpha never rises as distance from the core grows. let row = [ for dx in 0 .. radius -> alpha.[radius + 1, radius + 1 + dx] ] Assert.IsTrue(row |> List.pairwise |> List.forall (fun (a, b) -> a >= b), "glow must fade radially, not form a flat block") Assert.IsTrue(row.[0] = center && row.[radius] < center, "core is brightest, rim is dimmer") [] member _.LanternFalloffIsMaxAtCoreAndMonotonicWithoutPlateau () = // P29:夜景灯光衰减函数——半径 0 处最大(=1),向半径边缘严格单调递减至 0,无平台。 let radius = 96.0f Assert.AreEqual(1.0f, VillageArt.lanternFalloff 0.0f radius) Assert.AreEqual(0.0f, VillageArt.lanternFalloff radius radius) Assert.AreEqual(0.0f, VillageArt.lanternFalloff (radius + 7.0f) radius) Assert.IsTrue(abs (VillageArt.lanternFalloff 0.0f 0.0f) < 1e-6f, "zero radius must contribute no light") let samples = [ for i in 0 .. int radius -> VillageArt.lanternFalloff (float32 i) radius ] for value in samples do Assert.IsTrue(value >= 0.0f && value <= 1.0f, "falloff must stay within [0,1]") samples |> List.pairwise |> List.iter (fun (a, b) -> Assert.IsTrue(b < a, sprintf "falloff must strictly decrease (no plateau): %f then %f" a b)) // 二次衰减:一半半径处约为 0.75,且严格高于线性 0.5。 Assert.IsTrue(abs (VillageArt.lanternFalloff (radius / 2.0f) radius - 0.75f) < 1e-4f) [] member _.NightLightTilesCombineBridgeLanternsAndHouseWindows () = // P29:夜景光源 = 桥头灯笼 + 民居门窗暖光,确定性纯函数。 let map = MapGen.generateWithSize 512 384 (uint64 4242) let lights = VillageArt.nightLightTiles map Assert.IsTrue(lights.Length > 0, "large map must emit night lights") Assert.IsTrue(lights.Length > map.Buildings.Length, "windows add light sources beyond the houses themselves") Assert.AreEqual<(int * int) list>(lights, VillageArt.nightLightTiles map) for lantern in VillageArt.bridgeLanternTiles map do Assert.IsTrue(lights |> List.contains lantern, sprintf "bridge lantern (%d,%d) must be a light source" (fst lantern) (snd lantern)) for building in map.Buildings do Assert.IsTrue(lights |> List.contains (building.DoorX, building.DoorY), "every house door must carry warm light") [] member _.CharacterFeetSitOnTheTileGroundLine () = Assert.AreEqual(16, VillageArt.characterFeetOffset) let destination = VillageArt.characterDestination { X = 100.0f; Y = 200.0f } (Vector2(0.0f, 0.0f)) // Feet (sprite bottom) land half a tile below the tile centre; the sprite stays x-centred. Assert.AreEqual(216, destination.Y + destination.Height) Assert.AreEqual(100, destination.X + destination.Width / 2) Assert.AreEqual(32, destination.Width) Assert.AreEqual(48, destination.Height) [] member _.RiverBandSpansTheSceneWithBankPathsAndCrossing () = let plan = VillageArt.sampleRenderPlan () let footprintOf (value: VillageArt.JiangnanRenderPlan) = VillageArt.planFootprint value let waters = plan.Grounds |> List.filter (fun (_, kind) -> kind = VillageArt.Water) |> List.map fst |> Set.ofList let paths = plan.Grounds |> List.filter (fun (_, kind) -> kind = VillageArt.StonePath) |> List.map fst |> Set.ofList let door = scene.Homes |> List.head |> fun home -> home.Door // The river must run the full width of the decorated scene as a readable water band. Assert.AreEqual(20, [ 0 .. 19 ] |> List.filter (fun x -> waters.Contains({ X = x; Y = 12 } : TilePosition)) |> List.length) Assert.IsTrue(waters.Contains({ X = 6; Y = 13 } : TilePosition)) // Banks stay walkable stone, and the door row connects to the north bank. for x in 0 .. 19 do if x <> 6 && x <> 7 && x <> 8 then Assert.IsTrue(paths.Contains({ X = x; Y = 11 } : TilePosition), sprintf "missing north bank x=%d" x) for y in 6 .. 10 do Assert.IsTrue(paths.Contains({ X = 6; Y = y } : TilePosition), sprintf "missing door connector y=%d" y) // Bridge footprint crosses the full river between the banks. let bridge = plan.Props |> List.find (fun prop -> prop.Kind = VillageArt.Bridge) let expectedBridge : TilePosition = { X = 6; Y = 11 } Assert.AreEqual(expectedBridge, bridge.Position) for y in 11 .. 13 do let bridgeTile = VillageArt.worldTile plan { X = 6; Y = y } Assert.IsTrue(footprintOf plan |> List.contains bridgeTile, sprintf "bridge tile y=%d" y) [] member _.FacingUsesTheActualMovementOrTargetVector () = Assert.AreEqual(VillageArt.EastFacing, VillageArt.directionFromVector { X = 1.0f; Y = 0.1f }) Assert.AreEqual(VillageArt.WestFacing, VillageArt.directionFromVector { X = -1.0f; Y = 0.1f }) Assert.AreEqual(VillageArt.SouthFacing, VillageArt.directionFromVector { X = 0.1f; Y = 1.0f }) Assert.AreEqual(VillageArt.NorthFacing, VillageArt.directionFromVector { X = 0.1f; Y = -1.0f }) [] member _.RuntimeMovementAndNpcTargetsProduceStableSpriteDirections () = let idle = VillageArt.facingAfterMovement { X = 4.0f; Y = 4.0f } { X = 4.0f; Y = 4.0f } VillageArt.EastFacing let moved = VillageArt.facingAfterMovement { X = 4.0f; Y = 4.0f } { X = 2.0f; Y = 4.0f } VillageArt.EastFacing let targetSpec = VillageArt.npcSpriteSpecAtTarget (NpcId 0) { X = 10.0f; Y = 10.0f } { X = 10.0f; Y = 8.0f } 4L let arrivedSpec = VillageArt.npcSpriteSpecAtTarget (NpcId 0) { X = 10.0f; Y = 10.0f } { X = 10.0f; Y = 10.0f } 4L Assert.AreEqual(VillageArt.EastFacing, idle) Assert.AreEqual(VillageArt.WestFacing, moved) Assert.AreEqual(VillageArt.NorthFacing, targetSpec.Direction) // Walking toward a target uses the walk cycle; reaching it settles into the breath cycle. Assert.AreEqual(WalkFrame FrameTwo, targetSpec.Frame) Assert.AreEqual(IdleFrame IdleOne, arrivedSpec.Frame) [] member _.RenderPlanCoordinatesAndCharacterAtlasCellsAreDeterministic () = let plan = VillageArt.sampleRenderPlan () let localTile : TilePosition = { X = 6; Y = 4 } let worldTile = VillageArt.worldTile plan localTile let expectedWorldTile : TilePosition = { X = 31; Y = 22 } Assert.AreEqual(expectedWorldTile, worldTile) let first = VillageArt.characterSourceRectangle { Variant = IndigoRobe; Direction = NorthFacing; Frame = WalkFrame FrameOne } let second = VillageArt.characterSourceRectangle { Variant = JadeSash; Direction = EastFacing; Frame = WalkFrame FrameTwo } Assert.AreNotEqual(first, second) Assert.AreEqual(32, first.Width) Assert.AreEqual(48, first.Height) Assert.AreEqual(32, second.Width) Assert.AreEqual(48, second.Height) [] member _.FormalChineseUiLabelsUseTheRealGlyphAtlasBoundary () = Assert.IsTrue(ChineseText.requiredUiLabels |> List.forall PixelText.isRenderable) Assert.IsTrue(ChineseText.requiredUiLabels |> List.forall (fun label -> not (label.Contains("?")))) Assert.IsFalse(PixelText.isRenderable "龘") [] member _.ContextualPromptSelectsOneStableReachableNpcAndClearsWhenLeaving () = let avatar : Vec2 = { X = float32 (32 * Sim.tilePixels + Sim.tilePixels / 2) Y = float32 (24 * Sim.tilePixels + Sim.tilePixels / 2) } let world = Sim.initialWorldN 42UL 4 |> fun value -> { value with Avatar = { value.Avatar with Pos = avatar } } let npcPosition : Vec2 = { X = world.Avatar.Pos.X + 48.0f; Y = world.Avatar.Pos.Y } let npc = { world.Npcs.[0] with Pos = npcPosition } let npcs = Array.copy world.Npcs npcs.[0] <- npc for index in 1 .. npcs.Length - 1 do npcs.[index] <- { npcs.[index] with Pos = ({ X = 2000.0f; Y = 1400.0f } : Vec2) } let nearby = { world with Npcs = npcs } let prompt = InteractionResolver.resolve nearby Outside Assert.IsTrue(prompt.IsSome) Assert.AreEqual(TalkTo(NpcId 0), prompt.Value.Action) Assert.AreEqual("E 交谈", prompt.Value.Text) let view = M5Interaction.refreshPrompt nearby M5Interaction.initial Assert.AreEqual(Some "E 交谈", view.Prompt) let farAway = { nearby with Avatar = { nearby.Avatar with Pos = ({ X = 0.0f; Y = 0.0f } : Vec2) } } let cleared = M5Interaction.refreshPrompt farAway view Assert.AreEqual(None, cleared.Prompt) [] member _.HomeEntryWinsDeterministicallyAndUsesTheSameActionAsThePrompt () = let doorPixel : PixelPosition = { X = float32 (31 * Sim.tilePixels + Sim.tilePixels / 2) Y = float32 (23 * Sim.tilePixels + Sim.tilePixels / 2) } let world = Sim.initialWorldN 42UL 0 |> fun value -> { value with Avatar = { value.Avatar with Pos = ({ X = doorPixel.X; Y = doorPixel.Y } : Vec2) } } let prompt = InteractionResolver.resolve world Outside Assert.IsTrue(prompt.IsSome) Assert.AreEqual(EnterHome(HomeId 1), prompt.Value.Action) Assert.AreEqual("E 进屋", prompt.Value.Text) let _, view = M5Interaction.apply Interact world M5Interaction.initial Assert.AreEqual(Inside(HomeId 1), view.HomeMode) Assert.AreEqual(None, view.Prompt) [] member _.WallBlocksNpcPromptAndOpenPanelsDoNotAcceptWorldInput () = let avatar : Vec2 = { X = float32 (32 * Sim.tilePixels + Sim.tilePixels / 2); Y = float32 (24 * Sim.tilePixels + Sim.tilePixels / 2) } let npcPosition : Vec2 = { X = avatar.X - 40.0f; Y = avatar.Y - 80.0f } let world = Sim.initialWorldN 42UL 1 let npc = { world.Npcs.[0] with Pos = npcPosition } let blockedWorld = { world with Avatar = { world.Avatar with Pos = avatar }; Npcs = [| npc |] } Assert.IsFalse(InteractionResolver.hasClearPath avatar npcPosition) Assert.IsFalse(M5Interaction.worldInputAllowed { M5Interaction.initial with Panel = DialoguePanel }) Assert.IsTrue(M5Interaction.worldInputAllowed M5Interaction.initial) Assert.IsTrue(M5Interaction.resolveAction blockedWorld Outside |> Option.isNone) [] member _.TargetSelectionUsesPureDistancePriorityWithIdTiebreak () = let doorPixel : PixelPosition = { X = float32 (31 * Sim.tilePixels + Sim.tilePixels / 2) Y = float32 (23 * Sim.tilePixels + Sim.tilePixels / 2) } let avatar : Vec2 = { X = doorPixel.X - 32.0f; Y = doorPixel.Y } let world = Sim.initialWorldN 42UL 2 // Door nearer than the villager: door wins by pure distance (no kind privilege). let doorNearer = { world with Avatar = { world.Avatar with Pos = avatar } Npcs = [| { world.Npcs.[0] with Pos = { X = avatar.X + 40.0f; Y = avatar.Y } } |] } Assert.AreEqual(Some(EnterHome(HomeId 1)), InteractionResolver.resolve doorNearer Outside |> Option.map (fun candidate -> candidate.Action)) // NPC nearer than the door: villager wins. let npcNearerWorld = { doorNearer with Npcs = [| { world.Npcs.[0] with Pos = { X = avatar.X + 4.0f; Y = avatar.Y } } |] } Assert.AreEqual(Some(TalkTo(NpcId 0)), InteractionResolver.resolve npcNearerWorld Outside |> Option.map (fun candidate -> candidate.Action)) // Exact distance tie between two villagers: lower entity id wins deterministically. let tieAvatar : Vec2 = { X = 1600.0f; Y = 800.0f } let tieWorld = { world with Avatar = { world.Avatar with Pos = tieAvatar } Npcs = [| { world.Npcs.[0] with Pos = { X = tieAvatar.X + 64.0f; Y = tieAvatar.Y } } { world.Npcs.[1] with Pos = { X = tieAvatar.X - 64.0f; Y = tieAvatar.Y } } |] } Assert.AreEqual(Some(TalkTo(NpcId 0)), InteractionResolver.resolve tieWorld Outside |> Option.map (fun candidate -> candidate.Action)) // Array snapshot immutability: neither resolve nor refreshPrompt may mutate Npcs. let snapshot = Array.copy tieWorld.Npcs let resolvedIgnore = InteractionResolver.resolve tieWorld Outside CollectionAssert.AreEqual(snapshot, tieWorld.Npcs) let view = M5Interaction.refreshPrompt tieWorld M5Interaction.initial CollectionAssert.AreEqual(snapshot, tieWorld.Npcs) [] member _.FloatingPromptStaysInViewportAndClearOfHudBands () = for (width, height) in [ (1280, 720); (1920, 1080) ] do let anchors : (float32 * float32) list = [ (100.0f, 100.0f) // near top-left (float32 width / 2.0f, float32 height / 2.0f) (float32 width - 60.0f, 120.0f) // near top-right strip (200.0f, float32 height - 200.0f) // above bottom-left HUD band (float32 width - 100.0f, float32 height - 120.0f) ] for (anchorX, anchorY) in anchors do for text in [ "E 交谈"; "E 进屋"; "E 出屋" ] do let rect = FloatingPrompt.layout width height anchorX anchorY 0.0f 0.0f 2 text let textWidth = FloatingPrompt.measureWidth text 2 Assert.AreEqual(textWidth, rect.Width) Assert.IsTrue(rect.Width > 0, "prompt must not be truncated to zero width") Assert.IsTrue(rect.X >= 0 && rect.X + rect.Width <= width, sprintf "x overflow %A %A" (anchorX, anchorY) rect) Assert.IsTrue(rect.Y >= 0 && rect.Y + rect.Height <= height, sprintf "y overflow %A %A" (anchorX, anchorY) rect) // Never cover the bottom-left HUD band (camera 0: band starts at height - 160). Assert.IsTrue(rect.Y + rect.Height <= height - FloatingPrompt.hudBottomBandHeight + 8, sprintf "bottom HUD overlap %A %A" (anchorX, anchorY) rect) // Never cover the full-width top status bar. Assert.IsTrue(rect.Y >= FloatingPrompt.hudTopBarHeight, sprintf "top HUD overlap %A %A" (anchorX, anchorY) rect) [] member _.FloatingPromptMeasureMatchesPixelTextAdvances () = // E(12) space(8) 交(16) 谈(16) at scale 2. Assert.AreEqual(52, FloatingPrompt.measureWidth "E 交谈" 2) Assert.AreEqual(20, FloatingPrompt.measureWidth "E " 2) [] member _.FloatingPromptHiddenWhilePanelsOrMenusAreOpen () = let world = Sim.initialWorldN 42UL 2 let dialogueView = { M5Interaction.initial with Panel = DialoguePanel } let cleared = M5Interaction.refreshPrompt world dialogueView Assert.AreEqual(None, cleared.Prompt) Assert.IsFalse(M5Interaction.worldInputAllowed dialogueView) let needsView = { M5Interaction.initial with Panel = NeedsPanel } Assert.AreEqual(None, M5Interaction.refreshPrompt world needsView |> fun value -> value.Prompt) [] member _.DayPhaseLightingIsPureDeterministicAndTransitionsSmoothly () = let ticksPerHour = Sim.ticksPerDay / 24L let atHour hour = M6Presentation.lightingAtTick (int64 hour * ticksPerHour) // Anchors: full day at noon, full night at midnight, no drift. Assert.AreEqual(0.0f, (atHour 12).Blend) Assert.AreEqual(1.0f, (atHour 0).Blend) Assert.AreEqual(1.0f, (atHour 2).Blend) // P17 window: dawn 4:00 -> 6:30 goes night -> day, monotonic (fractional hours). let dawnMidTick = int64 5 * ticksPerHour + ticksPerHour / 4L // 5:15 let dawnEndTick = int64 6 * ticksPerHour + ticksPerHour / 2L // 6:30 Assert.AreEqual(1.0f, (atHour 4).Blend) Assert.AreEqual(0.5f, (M6Presentation.lightingAtTick dawnMidTick).Blend) Assert.AreEqual(0.0f, (M6Presentation.lightingAtTick dawnEndTick).Blend) // P18: dusk darkening ramp is 20:00 -> 22:00 (day -> night, monotonic). let duskStartTick = int64 20 * ticksPerHour // 20:00 let duskMidTick = int64 21 * ticksPerHour // 21:00 Assert.AreEqual(0.0f, (M6Presentation.lightingAtTick duskStartTick).Blend) Assert.AreEqual(0.5f, (M6Presentation.lightingAtTick duskMidTick).Blend) Assert.AreEqual(1.0f, (atHour 22).Blend) // Blue hour: the dawn lingers past 6:00; dusk stays light while it warms. Assert.IsTrue((atHour 6).Blend > 0.0f && (atHour 6).Blend < 0.5f) let duskGlowTick = int64 20 * ticksPerHour + ticksPerHour / 2L // 20:30 Assert.IsTrue((M6Presentation.lightingAtTick duskGlowTick).Blend > 0.0f) Assert.IsTrue((M6Presentation.lightingAtTick duskGlowTick).Blend < 0.5f, "20:30 must still be Day mode for a visible golden dusk") Assert.AreEqual(Day, (M6Presentation.lightingAtTick duskGlowTick).Mode) // Golden-hour warmth peaks mid-dawn (5:15) / mid-dusk (20:30) and vanishes at noon/midnight. let duskWarmthMidTick = int64 20 * ticksPerHour + ticksPerHour / 2L // 20:30 Assert.AreEqual(0.0f, (atHour 12).SkyWarmth) Assert.AreEqual(0.0f, (atHour 0).SkyWarmth) Assert.AreEqual(1.0f, (M6Presentation.lightingAtTick dawnMidTick).SkyWarmth) Assert.AreEqual(1.0f, (M6Presentation.lightingAtTick duskWarmthMidTick).SkyWarmth) Assert.IsTrue((atHour 5).SkyWarmth > 0.5f) Assert.IsTrue((atHour 21).SkyWarmth > 0.5f) // The warm shift pushes sky and world tint redder and less blue than the plain blend; // this is what made the old dusk frame still read blue-grey. let warmDawn = atHour 5 let warmDusk = M6Presentation.lightingAtTick duskGlowTick Assert.IsTrue(warmDawn.Background.R > (M6Presentation.backgroundAtBlend warmDawn.Blend).R) Assert.IsTrue(warmDawn.Background.B < (M6Presentation.backgroundAtBlend warmDawn.Blend).B) Assert.IsTrue(warmDusk.Background.R > (M6Presentation.backgroundAtBlend warmDusk.Blend).R) Assert.IsTrue(warmDusk.Background.B < (M6Presentation.backgroundAtBlend warmDusk.Blend).B) Assert.IsTrue(warmDusk.WorldTint.R > (M6Presentation.worldTintAtBlend warmDusk.Blend).R) Assert.IsTrue(warmDusk.WorldTint.B < (M6Presentation.worldTintAtBlend warmDusk.Blend).B) // P19: no daylight warm cast. Noon (and the whole 10:00-15:00 plateau) must be warmth-free // and channel-neutral, so the world renders at a true white balance instead of yellow. for hour in 10 .. 15 do let daylight = atHour hour Assert.IsTrue(abs (daylight.SkyWarmth) < 1e-6f, sprintf "hour %d must carry no warmth" hour) Assert.IsTrue((int daylight.WorldTint.R) = (int daylight.WorldTint.G), sprintf "hour %d world tint must be neutral" hour) Assert.IsTrue((int daylight.WorldTint.G) = (int daylight.WorldTint.B), sprintf "hour %d world tint must be neutral" hour) Assert.IsTrue((M6Presentation.goldenVeilAlpha (int64 hour * ticksPerHour)) = 0, sprintf "hour %d must have no golden veil" hour) Assert.IsTrue(abs (daylight.LanternGlow) < 1e-6f, sprintf "hour %d must have no lantern glow" hour) let noon = atHour 12 Assert.IsTrue((int noon.WorldTint.R) = 255, "neutral day tint should be full white (no attenuation)") // Additive veil: positive and strongest at the golden peaks, zero at noon/midnight. Assert.AreEqual(0, M6Presentation.goldenVeilAlpha (int64 12 * ticksPerHour)) Assert.AreEqual(0, M6Presentation.goldenVeilAlpha 0L) Assert.AreEqual(150, M6Presentation.goldenVeilAlpha duskWarmthMidTick) Assert.IsTrue(M6Presentation.goldenVeilAlpha duskGlowTick > 100) Assert.IsTrue(M6Presentation.goldenVeilAlpha (int64 19 * ticksPerHour) = 0, "veil must start after 19:15, not bleed into afternoon") // Determinism without randomness. let once = atHour 13 let twice = atHour 13 Assert.AreEqual(once, twice) // Lantern glow now also lifts through the golden hour, not only at deep night. Assert.AreEqual(0.0f, (atHour 13).LanternGlow) Assert.AreEqual(1.0f, (atHour 0).LanternGlow) Assert.IsTrue(warmDusk.LanternGlow > 0.5f) // Evidence hook: fractional start hours map exactly onto sim ticks. Assert.AreEqual(int64 20 * ticksPerHour + ticksPerHour * 3L / 4L, M6Presentation.startTickForHour 20.75) Assert.AreEqual(6L * ticksPerHour, M6Presentation.startTickForHour 6.0) [] member _.StartTickResolutionHonoursEvidenceHourThenDaylightThenMidnight () = let ticksPerHour = Sim.ticksPerDay / 24L // P17-FIX: the evidence hour must win on the sample/autoplay path too, otherwise // StartNewGame re-created the world at 00:00 and dusk/day shots were actually night. Assert.AreEqual(M6Presentation.startTickForHour 20.75, M6Presentation.resolveStartTick (Some 20.75) false) Assert.AreEqual(int64 20 * ticksPerHour + ticksPerHour * 3L / 4L, M6Presentation.resolveStartTick (Some 20.75) false) // Explicit hour overrides the daylight bootstrap. Assert.AreEqual(12L * ticksPerHour, M6Presentation.resolveStartTick (Some 12.0) true) // No override: daylight -> 6:00, otherwise midnight. Assert.AreEqual(6L * ticksPerHour, M6Presentation.resolveStartTick None true) Assert.AreEqual(0L, M6Presentation.resolveStartTick None false) [] member _.TitleEntranceProgressIsDeterministicAndCompleteAfterTwoSeconds () = // 120 frames at the fixed 60Hz step = 2s entrance, pure function of frame count. Assert.AreEqual(1.0f, TitleScreen.entranceProgress 0L) Assert.AreEqual(0.5f, TitleScreen.entranceProgress 60L) Assert.AreEqual(0.0f, TitleScreen.entranceProgress 120L) Assert.AreEqual(0.0f, TitleScreen.entranceProgress 1000L) Assert.AreEqual(TitleScreen.entranceProgress 37L, TitleScreen.entranceProgress 37L) [] member _.ProceduralMapGeneratesByteIdenticalTerrainFromSameSeed () = let first = ProceduralMap.generate (uint64 4242) let second = ProceduralMap.generate (uint64 4242) Assert.AreEqual(first.Tiles.Length, second.Tiles.Length) let snapshot = Array.copy first.Tiles let mutable diverged = -1 for i in 0 .. snapshot.Length - 1 do if snapshot.[i] <> second.Tiles.[i] then diverged <- i Assert.AreEqual(-1, diverged) // Different seed must produce different terrain (sanity). let other = ProceduralMap.generate (uint64 777) let differs = [0 .. snapshot.Length - 1] |> List.tryFind (fun i -> other.Tiles.[i] <> snapshot.[i]) Assert.IsTrue(differs.IsSome, "different seed should carve different terrain") // Village core stays walkable: no water/peat inside the handcrafted core rect. let coreBad = [ for y in ProceduralMap.villageCoreMinY .. ProceduralMap.villageCoreMaxY do for x in ProceduralMap.villageCoreMinX .. ProceduralMap.villageCoreMaxX do let code = first.Tiles.[y * ProceduralMap.width + x] if code = ProceduralMap.GroundTile.Water || code = ProceduralMap.GroundTile.Peat then yield (x, y, code) ] Assert.AreEqual<(int * int * int) list>([], coreBad) [] member _.WorldPanelDoesNotRenderA永久KeyList () = let world = Sim.initialWorldN 42UL 0 let lines = M5Interaction.panelLines world M5Interaction.initial Assert.AreEqual([], lines) Assert.IsFalse(lines |> List.exists (fun line -> line = "Q 观察" || line = "TAB 需求" || line = "C 年鉴" || line = "L 关系")) [] member _.WorldPanelStatusIsTransientAndExpiresAfterTwoAndAHalfSeconds () = let world = Sim.initialWorldN 42UL 0 let _, interacted = M5Interaction.apply ClosePanel world M5Interaction.initial let stamped = { interacted with StatusTick = Some world.Tick } Assert.AreEqual([ "E 进屋"; "面板已关闭" ], M5Interaction.panelLines world stamped) let expired = { stamped with StatusTick = Some (world.Tick - M5Interaction.statusDisplayDurationTicks) } Assert.AreEqual([ "E 进屋" ], M5Interaction.panelLines world expired) [] member _.DialogueCompletionReturnsToTheLightweightWorldPanel () = let world = Sim.initialWorldN 42UL 4 |> fun value -> { value with Avatar = { value.Avatar with Pos = value.Npcs.[0].Pos } } let openedWorld, opened = M5Interaction.apply Interact world M5Interaction.initial Assert.AreEqual(DialoguePanel, opened.Panel) Assert.IsTrue(opened.Menu.IsSome) let nextWorld, view = M5Interaction.apply Intent1 openedWorld opened Assert.AreEqual(WorldPanel, view.Panel) Assert.AreEqual(None, view.Menu) Assert.AreEqual(Some nextWorld.Tick, view.StatusTick) Assert.IsTrue(M5Interaction.statusText view |> fun text -> text.Contains("对话完成")) Assert.IsTrue(M5Interaction.transientStatusLine nextWorld.Tick view |> Option.map (fun line -> line.Contains("对话完成")) |> Option.defaultValue false) [] member _.InteractionRangeAndTargetSelectionStayDeterministicAtTheBoundary () = let avatar : Vec2 = { X = float32 (32 * Sim.tilePixels + Sim.tilePixels / 2) Y = float32 (24 * Sim.tilePixels + Sim.tilePixels / 2) } let baseWorld = Sim.initialWorldN 42UL 4 let withNpcPositions (positions: (int * Vec2) list) = let npcs = Array.copy baseWorld.Npcs for index in 0 .. npcs.Length - 1 do npcs.[index] <- { npcs.[index] with Pos = ({ X = 2000.0f; Y = 1400.0f } : Vec2) } for index, position in positions do npcs.[index] <- { npcs.[index] with Pos = position } { baseWorld with Avatar = { baseWorld.Avatar with Pos = avatar } Npcs = npcs } let atBoundary = withNpcPositions [ 0, { X = avatar.X + Sim.chatRangePx; Y = avatar.Y } ] let beyondBoundary = withNpcPositions [ 0, { X = avatar.X + Sim.chatRangePx + 0.1f; Y = avatar.Y } ] let competing = withNpcPositions [ 0, { X = avatar.X + 72.0f; Y = avatar.Y } 1, { X = avatar.X + 48.0f; Y = avatar.Y } ] let switched = { competing with Avatar = { competing.Avatar with Pos = { X = avatar.X + 64.0f; Y = avatar.Y } } } Assert.AreEqual(Some(TalkTo(NpcId 0)), InteractionResolver.resolve atBoundary Outside |> Option.map (fun target -> target.Action)) Assert.AreEqual(None, InteractionResolver.resolve beyondBoundary Outside |> Option.map (fun target -> target.Action)) Assert.AreEqual(Some(TalkTo(NpcId 1)), InteractionResolver.resolve competing Outside |> Option.map (fun target -> target.Action)) Assert.AreEqual(Some(TalkTo(NpcId 0)), InteractionResolver.resolve switched Outside |> Option.map (fun target -> target.Action)) [] member _.CjkGlyphAtlasHasAStableCharacterTableAndGeometry () = Assert.AreEqual(345, characters.Length) CollectionAssert.AreEqual(characters, characters |> Array.sort) Assert.AreEqual(characters.Length, characters |> Array.distinct |> Array.length) Assert.AreEqual(16, columns) Assert.AreEqual(24, cellPixels) Assert.AreEqual(22, rowCount) Assert.AreEqual(384, pixelWidth) Assert.AreEqual(528, pixelHeight) Assert.AreEqual("Assets/cjk-glyph-atlas.png", assetRelativePath) Assert.AreEqual("Assets/cjk-glyphs.txt", manifestRelativePath) [] member _.RequiredUiLabelsAreFullyCoveredByTheGlyphTable () = let labels = ChineseText.requiredUiLabels Assert.IsTrue(labels |> List.forall PixelText.isRenderable) Assert.IsTrue(labels |> List.contains "时间") Assert.IsTrue(labels |> List.contains "E 出屋") Assert.IsTrue(labels |> List.contains "E 进屋") Assert.IsTrue(labels |> List.contains "E 交谈") Assert.IsTrue(PixelText.isRenderable "时间 白天") Assert.IsTrue(PixelText.isRenderable "2倍速") Assert.IsTrue(PixelText.isRenderable "E 出门") Assert.IsTrue(PixelText.isRenderable "E 交谈") Assert.IsFalse(PixelText.isRenderable "龘") [] member _.CjkGlyphAtlasMatchesThePackagedManifestAndRejectsUnknownCharacters () = Assert.IsTrue(ChineseText.requiredUiLabels |> List.forall PixelText.isRenderable) Assert.IsTrue(contains '江') Assert.IsTrue(contains '间') Assert.IsTrue(contains '门') Assert.IsTrue(contains '谈') Assert.IsTrue(contains '体') Assert.IsTrue(contains '龘' |> not) Assert.AreEqual(0, sourceRectangle '龘' |> Option.toList |> List.length) let manifestPath = runtimeManifestPath () Assert.IsTrue(File.Exists manifestPath, sprintf "Missing atlas manifest: %s" manifestPath) let manifestLines = File.ReadAllLines manifestPath Assert.AreEqual(characters.Length, manifestLines.Length) Assert.IsTrue(manifestLines |> Array.forall (fun line -> line.Length = 1), "The CJK manifest must contain exactly one character per line.") CollectionAssert.AreEqual(characters, manifestLines |> Array.map (fun line -> line.[0])) Assert.IsTrue(File.Exists(runtimeAssetPath ()), sprintf "Missing atlas PNG: %s" (runtimeAssetPath ())) [] member _.WorldDigestIsCanonicalAcrossSaveFormatVersions () = // The digest must bind to simulation state only: a world presented as a // legacy V1 save text and as the new V2 save text must share one digest. let world = Sim.initialWorldN (uint64 42) 4 let v2Text = WorldSave.save world let firstPipe = v2Text.IndexOf('|') let secondPipe = v2Text.IndexOf('|', firstPipe + 1) let thirdPipe = v2Text.IndexOf('|', secondPipe + 1) let v1Text = ("LV_WORLD_SAVE_V1" + v2Text.Substring(thirdPipe)) // v1/v2 same-world digest equality (RED before canonical digest exists). let digestOfV1 = PerformanceProbe.WorldDigestOfText v1Text Assert.AreEqual(digestOfV1, PerformanceProbe.WorldDigestOfText v2Text) // 953775FA 硬对齐由 --performance-baseline 于 finalWorld 支线证据。 [] member _.HouseFacadeVariantsAlternateAlongTheVillage () = // P26:512 大图上两种民居立面都要出现,且按位置确定性交替(纯函数)。 let map = MapGen.generateWithSize 512 384 (uint64 4242) let variants = map.Buildings |> List.map VillageArt.houseFacadeVariant Assert.IsTrue(Set.ofList variants |> Set.contains 0, "variant A must appear") Assert.IsTrue(Set.ofList variants |> Set.contains 1, "variant B must appear") let repeated = MapGen.generateWithSize 512 384 (uint64 4242) Assert.AreEqual<(int list)>(variants, repeated.Buildings |> List.map VillageArt.houseFacadeVariant) // Neighbouring buildings along the same river bank must flip variant, i.e. not one solid run. let flipped = variants |> List.pairwise |> List.filter (fun (a, b) -> a <> b) |> List.length Assert.IsTrue(flipped > 0, "facades must alternate, not stay one variant") [] member _.JiangnanArtAssetsArePackagedAsRealPngFiles () = let requiredAssets = [ "Assets/jiangnan-world.png" "Assets/jiangnan-characters.png" "Assets/jiangnan-interior.png" ] let missing = requiredAssets |> List.filter (fun relativePath -> not (File.Exists(Path.Combine(AppContext.BaseDirectory, relativePath)))) Assert.AreEqual([], missing, sprintf "Missing Jiangnan PNG assets: %s" (String.concat ", " missing)) // The baked world atlas must match the 32-slot layout the renderer indexes into // (P17 added 石灯笼/水缸/摊贩摊位 as slots 25..27; P26 added the variant-B house // parts 屋檐挑出/木格栅窗/院门 as slots 28..30 and 护岸石垒 as slot 31). Assert.AreEqual(32, VillageArt.worldTileCount) let worldPath = Path.Combine(AppContext.BaseDirectory, "Assets/jiangnan-world.png") let header = File.ReadAllBytes worldPath let be32 offset = (int header.[offset] <<< 24) ||| (int header.[offset + 1] <<< 16) ||| (int header.[offset + 2] <<< 8) ||| int header.[offset + 3] let atlasWidth : int = be32 16 let atlasHeight : int = be32 20 Assert.IsTrue((atlasWidth = VillageArt.worldAtlasWidth), "world atlas width must match 32 x 32px slots") Assert.IsTrue((atlasHeight = VillageArt.worldAtlasHeight), "world atlas height must stay 32px") /// P28:防复发。第二变体(P26)在 512 整图 ~2px/tile 下不可辨,根因是挑檐与第一变体同为 /// 近黑黛瓦。此回归直接解码 world 图集,断言变体 B 的挑檐(slot 28)平均亮度显著高于 /// 变体 A 的屋脊(slot 8),保证「配色差异」不会在后续编辑中被悄悄抹平。 [] member _.VariantBRoofPaletteIsClearlyLighterThanVariantA () = let worldPath = Path.Combine(AppContext.BaseDirectory, "Assets/jiangnan-world.png") let bytes = File.ReadAllBytes worldPath let be32 offset = (int bytes.[offset] <<< 24) ||| (int bytes.[offset + 1] <<< 16) ||| (int bytes.[offset + 2] <<< 8) ||| int bytes.[offset + 3] Assert.AreEqual(0x89504E47, be32 0) let width = be32 16 let height = be32 20 Assert.AreEqual(VillageArt.worldAtlasWidth, width) Assert.AreEqual(VillageArt.worldAtlasHeight, height) // Collect IDAT and inflate; the baked atlas is 8-bit RGBA, non-interlaced, filter 0. let mutable offset = 8 use idat = new MemoryStream() while offset < bytes.Length do let length = be32 offset let tag = System.Text.Encoding.ASCII.GetString(bytes, offset + 4, 4) if tag = "IDAT" then idat.Write(bytes, offset + 8, length) offset <- offset + 12 + length idat.Position <- 0L use inflater = new System.IO.Compression.ZLibStream(idat, System.IO.Compression.CompressionMode.Decompress) use raw = new MemoryStream() inflater.CopyTo raw let data = raw.ToArray() let stride = width * 4 + 1 let pixel (x: int) (y: int) = let rowStart = y * stride Assert.AreEqual(0uy, data.[rowStart]) let p = rowStart + 1 + x * 4 (int data.[p], int data.[p + 1], int data.[p + 2], int data.[p + 3]) let slotMeanBrightness (slot: int) = let mutable sum = 0.0 let mutable count = 0 for y in 0 .. height - 1 do for x in slot * 32 .. slot * 32 + 31 do let (r, g, b, a) = pixel x y if a > 0 then sum <- sum + float (r + g + b) / 3.0 count <- count + 1 Assert.IsTrue(count > 0, sprintf "slot %d must have opaque pixels" slot) sum / float count let roofA = slotMeanBrightness 8 // RoofRidgeSprite: variant A near-black 黛瓦 let roofB = slotMeanBrightness 28 // RoofEaveOverhangSprite: variant B light blue-grey 瓦 Assert.IsTrue( roofB > roofA + 15.0, sprintf "variant B roof must be clearly lighter than variant A (A=%.1f B=%.1f)" roofA roofB) [] member _.CjkGlyphManifestIsUniqueAndMatchesAtlasGrid () = // 防复发:图集用字增长时,清单必须非空、每行恰 1 个字符、字符唯一, // 且与 16 列图集网格自洽(打包脚本不再硬编码行数)。 let assets = Path.Combine(AppContext.BaseDirectory, "Assets") let manifestPath = Path.Combine(assets, "cjk-glyphs.txt") let atlasPath = Path.Combine(assets, "cjk-glyph-atlas.png") Assert.IsTrue(File.Exists manifestPath, "missing cjk-glyphs.txt") Assert.IsTrue(File.Exists atlasPath, "missing cjk-glyph-atlas.png") let lines = File.ReadAllLines manifestPath Assert.IsTrue(lines.Length > 0, "CJK manifest must not be empty") for index in 0 .. lines.Length - 1 do Assert.AreEqual(1, lines.[index].Length) Assert.AreEqual(lines.Length, (Set.ofArray lines).Count) let header = File.ReadAllBytes atlasPath let be32 offset = (int header.[offset] <<< 24) ||| (int header.[offset + 1] <<< 16) ||| (int header.[offset + 2] <<< 8) ||| int header.[offset + 3] let cell = 24 let width = be32 16 let height = be32 20 Assert.AreEqual(0, width % cell) Assert.AreEqual(0, height % cell) let columns = width / cell let capacity = columns * (height / cell) Assert.AreEqual(16, columns) Assert.IsTrue(capacity >= lines.Length, "CJK atlas grid must cover every manifest character") Assert.IsTrue(capacity - lines.Length < columns, "CJK atlas must not keep an extra empty row")