{"TotalCount":154,"Files":[{"Ident":"degg.city_sim","Path":"Ai/BotChecks.cs","FileName":"BotChecks.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":443612,"IsPrivate":false,"Code":"using System;\r\nusing System.Collections.Generic;\r\nusing System.Diagnostics;\r\nusing System.Linq;\r\nusing System.Text.Json;\r\nusing CitySim.Match;\r\n\r\nnamespace CitySim.Ai;\r\n\r\n/// \u003Csummary\u003EBounded, transport-independent M6 behavior checks. Full simulation sweeps belong to M8.\u003C/summary\u003E\r\npublic static class BotChecks\r\n{\r\n static readonly Guid[] Ids = Enumerable.Range(1,5).Select(i =\u003E new Guid($\u002200000000-0000-0000-0000-{i:000000000000}\u0022)).ToArray();\r\n public static IReadOnlyList\u003Cstring\u003E Run()\r\n {\r\n  var passed=new List\u003Cstring\u003E();\r\n  void Check(bool ok,string name) { if (!ok) throw new InvalidOperationException(name); passed.Add(\u0022PASS \u0022\u002Bname); }\r\n  string Json\u003CT\u003E(T x) =\u003E JsonSerializer.Serialize(x);\r\n  (MatchState State,RoundMachine Rounds,Bot[] Bots) Setup(int players,int side=9, int seed=144, BotDifficulty difficulty=BotDifficulty.Normal)\r\n  {\r\n   var state=new MatchState(side,side,seed,new MatchSettings());\r\n   for (var i=0;i\u003Cplayers;i\u002B\u002B) state.AddPlayer(Ids[i],$\u0022Player {i\u002B1}\u0022);\r\n   for (var i=0;i\u003Cside;i\u002B\u002B) { state.SeedStreet(i,side/2); if (i!=side/2) state.SeedStreet(side/2,i); }\r\n   var rounds=new RoundMachine(state);\r\n   return (state,rounds,Enumerable.Range(0,players).Select(i=\u003Enew Bot(Ids[i],state,rounds,difficulty,seed\u002Bi)).ToArray());\r\n  }\r\n  string Play(int players,int side, int seed,out double seconds)\r\n  {\r\n   var (state,rounds,bots)=Setup(players,side,seed); var watch=Stopwatch.StartNew();\r\n   var offered=Ids.Take(players).ToDictionary(id=\u003Eid,id=\u003Erounds.GetContractView(id).Choices.ToArray());\r\n   foreach (var bot in bots) bot.ChooseContract();\r\n   Check(rounds.ContractsChosen \u0026\u0026 Ids.Take(players).All(id=\u003Eoffered[id].Contains(rounds.GetContractView(id)?.Selected)),$\u0022{players} bots choose their offered contracts\u0022);\r\n   rounds.Start(0); var denied=0; state.Events.PlacementRejected\u002B=_=\u003Edenied\u002B\u002B;\r\n   for (var step=0;step\u003C30000 \u0026\u0026 state.Phase!=MatchPhase.EndGame;step\u002B\u002B)\r\n   {\r\n    var now=step/30.0;\r\n    foreach (var bot in bots) bot.Update(now);\r\n    foreach (var id in Ids.Take(players))\r\n    {\r\n     var auction=rounds.GetAuctionView(id);\r\n     if (auction!=null \u0026\u0026 !auction.Resolving \u0026\u0026 (auction.ReservedCoins\u003C0 || auction.AvailableCoins\u003C0 || auction.ReservedCoins\u002Bauction.AvailableCoins!=state.Players[id].Coins))\r\n      throw new InvalidOperationException(\u0022Bot exceeded its bid budget.\u0022);\r\n    }\r\n    if (state.Players.Values.Any(p=\u003Ep.Coins\u003C0)) throw new InvalidOperationException(\u0022Bot spent more coins than it owned.\u0022);\r\n    rounds.Tick(now);\r\n   }\r\n   watch.Stop(); seconds=watch.Elapsed.TotalSeconds;\r\n   Check(denied==0 \u0026\u0026 state.Phase==MatchPhase.EndGame \u0026\u0026 state.Round==state.Settings.Rounds \u0026\u0026 state.FinalScore?.Players.Count==players \u0026\u0026 state.ContractReveals.Count==players,$\u0022{players} bots finish an eight-round match with legal actions and final scores\u0022);\r\n   return Json(new { Match=state.Capture(), Rounds=rounds.Capture() });\r\n  }\r\n  var (sample,round,bots)=Setup(2); foreach(var bot in bots) bot.ChooseContract(); round.Start(0);\r\n  var initial=round.GetAuctionView(Ids[0]); bots[0].Update(0);\r\n  var immediate=round.GetAuctionView(Ids[0]);\r\n  Check(initial!=null \u0026\u0026 !immediate.Ready \u0026\u0026 immediate.Lots.All(l=\u003El.Amount==0),\u0022bot does not bid or ready on the initial scheduling tick\u0022);\r\n  var deadlineState=Json(sample.Capture()); var deadlineBid=Json(round.GetAuctionView(Ids[0]));\r\n  bots[0].Update(sample.PhaseEndsAt);\r\n  Check(deadlineState==Json(sample.Capture()) \u0026\u0026 deadlineBid==Json(round.GetAuctionView(Ids[0])),\u0022bot submits no action at the auction deadline\u0022);\r\n  for (var rivals=1;rivals\u003C=4;rivals\u002B\u002B)\r\n  {\r\n   var (s,r,agents)=Setup(rivals\u002B1);\r\n   foreach (var id in Ids.Take(rivals\u002B1)) { var offer=r.GetContractView(id); r.ChooseContract(id,offer.DraftId,offer.Choices[0]); }\r\n   r.Start(0);\r\n   for (var n=0;n\u003C1200 \u0026\u0026 Ids.Skip(1).Take(rivals).Any(id=\u003E!r.GetAuctionView(id).Ready);n\u002B\u002B) foreach (var bot in agents.Skip(1)) bot.Update(n/60.0);\r\n   Check(Ids.Skip(1).Take(rivals).All(id=\u003Er.GetAuctionView(id).Ready) \u0026\u0026 !r.GetAuctionView(Ids[0]).Ready \u0026\u0026 s.Phase==MatchPhase.Bid,$\u0022{rivals} rivals independently choose or pass while the market is untimed while the human remains unready\u0022);\r\n  }\r\n  (MatchState,RoundMachine,Bot) Placement()\r\n  {\r\n   var settings=new MatchSettings {EnableContracts=false,EnableAuctions=false,DefaultStreets=0,DefaultCommons=0,StubBidSeconds=0};\r\n   var s=new MatchState(9,9,900,settings); s.AddPlayer(Ids[0],\u0022Planner\u0022);\r\n   for (var i=0;i\u003C9;i\u002B\u002B) { s.SeedStreet(i,4); if (i!=4) s.SeedStreet(4,i); }\r\n   s.SetPhase(MatchPhase.Build,1,40); s.GiveCard(Ids[0],\u0022house\u0022); s.TryPlace(Ids[0],\u0022house\u0022,5,3);\r\n   s.SetPhase(MatchPhase.Lobby,0,0); s.GiveCard(Ids[0],\u0022house\u0022);\r\n   var r=new RoundMachine(s); var bot=new Bot(Ids[0],s,r,BotDifficulty.Hard,12); r.Start(0); r.Tick(0);\r\n   return (s,r,bot);\r\n  }\r\n  var (planned,plannedRounds,planner)=Placement();\r\n  var (expired,_,lateBot)=Placement(); lateBot.Update(0); var beforeDeadline=Json(expired.Capture()); lateBot.Update(expired.PhaseEndsAt);\r\n  Check(beforeDeadline==Json(expired.Capture()),\u0022bot performs no build action at the deadline\u0022);\r\n  var first=Enumerable.Range(0,81).Select(i=\u003E(X:i%9,Y:i/9)).First(p=\u003Eplanned.PlacementError(Ids[0],\u0022house\u0022,p.X,p.Y)==null);\r\n  var firstVp=PlacementScoring.Preview(planned,\u0022house\u0022,first.X,first.Y,Ids[0]);\r\n  for (var n=0;n\u003C360 \u0026\u0026 planned.Players[Ids[0]].Hand.Count\u003E0;n\u002B\u002B) planner.Update(n/60.0);\r\n  var chosen=planned.Cells().Single(c=\u003Ec.Owner==Ids[0] \u0026\u0026 c.PlacedRound==1 \u0026\u0026 (c.X!=5 || c.Y!=3));\r\n  Check(planned.Players[Ids[0]].Vp\u003EfirstVp \u0026\u0026 chosen!=null,\u0022hard bot chooses a VP-producing house placement over the first legal cell\u0022);\r\n  for (var n=360;n\u003C600 \u0026\u0026 !plannedRounds.IsBuildReady(Ids[0]);n\u002B\u002B) planner.Update(n/60.0);\r\n  Check(planned.Phase==MatchPhase.Payout || plannedRounds.IsBuildReady(Ids[0]),\u0022bot readies after completing its build actions\u0022);\r\n  var endSettings=new MatchSettings {EnableContracts=false,EnableAuctions=false,DefaultStreets=0,DefaultCommons=0};\r\n  var late=new MatchState(9,9,72,endSettings); late.AddPlayer(Ids[0],\u0022Planner\u0022);\r\n  late.SeedStreet(4,4); late.SetPhase(MatchPhase.Build,8,40); late.GiveCard(Ids[0],\u0022bank\u0022); late.GiveCard(Ids[0],\u0022monument\u0022);\r\n  var lateRounds=new RoundMachine(late); var monumentBot=new Bot(Ids[0],late,lateRounds,BotDifficulty.Hard,12);\r\n  for (int n=0;n\u003C360 \u0026\u0026 !late.Cells().Any(c=\u003Ec.Owner==Ids[0]);n\u002B\u002B) monumentBot.Update(n/60.0);\r\n  Check(late.Cells().Any(c=\u003Ec.Owner==Ids[0] \u0026\u0026 c.CardId==\u0022monument\u0022) \u0026\u0026 late.Players[Ids[0]].Hand.Contains(\u0022bank\u0022),\u0022late-round bot chooses six-VP Monument over a low-yield bank\u0022);\r\n  var paceSettings=new MatchSettings { EnableContracts=false,EnableGoals=false,EnableAuctions=false,DefaultCommons=0,DefaultStreets=0,StubBidSeconds=0 };\r\n  var paced=new MatchState(7,7,901,paceSettings); paced.AddPlayer(Ids[0],\u0022Bot\u0022); paced.AddPlayer(Ids[1],\u0022Human\u0022);\r\n  var pacedRounds=new RoundMachine(paced); pacedRounds.Start(0); pacedRounds.Tick(0);\r\n  for(var i=0;i\u003C3;i\u002B\u002B) paced.GiveCard(Ids[0],\u0022cottage\u0022);\r\n  paced.GiveCard(Ids[1],\u0022park\u0022);\r\n  var pacedBot=new Bot(Ids[0],paced,pacedRounds,BotDifficulty.Normal,15,\r\n   finishBuildingQuickly:()=\u003Epaced.Players[Ids[1]].Hand.Count==0 || pacedRounds.IsBuildReady(Ids[1]));\r\n  var placementTimes=new List\u003Cdouble\u003E();double clock=0;\r\n  paced.Events.CardPlaced\u002B=cell=\u003E{if(cell.Owner==Ids[0]) placementTimes.Add(clock);};\r\n  int tick=0;\r\n  for(;tick\u003C120;tick\u002B\u002B) {clock=tick/60.0;pacedBot.Update(clock);}\r\n  Check(placementTimes.Count==0,\u0022bot waits at least two seconds before its first build while the human is playing\u0022);\r\n  for(;tick\u003C600 \u0026\u0026 placementTimes.Count\u003C2;tick\u002B\u002B) {clock=tick/60.0;pacedBot.Update(clock);}\r\n  Check(placementTimes.Count==2 \u0026\u0026 placementTimes[0]\u003C=4.05,\u0022bot builds after the initial thinking pause\u0022);\r\n  Check(placementTimes[1]-placementTimes[0]\u003E=1.19,\u0022bot leaves at least 1.2 seconds between ordinary builds\u0022);\r\n  Check(paced.TryPlace(Ids[1],\u0022park\u0022,6,6).Success,\u0022human can finish their hand during the bot\u0027s build pause\u0022);\r\n  var finishedAt=clock;\r\n  for(;tick\u003C900 \u0026\u0026 !pacedRounds.IsBuildReady(Ids[0]);tick\u002B\u002B) {clock=tick/60.0;pacedBot.Update(clock);}\r\n  Check(paced.Players[Ids[0]].Hand.Count==0 \u0026\u0026 clock-finishedAt\u003C1.2,\u0022bot promptly finishes remaining builds when the human hand becomes empty\u0022);\r\n  Check(pacedRounds.IsBuildReady(Ids[0]),\u0022bot marks itself ready after finishing its remaining cards\u0022);\r\n  var readyPaced=new MatchState(7,7,902,paceSettings); readyPaced.AddPlayer(Ids[0],\u0022Bot\u0022); readyPaced.AddPlayer(Ids[1],\u0022Human\u0022);\r\n  var readyRounds=new RoundMachine(readyPaced); readyRounds.Start(0); readyRounds.Tick(0);\r\n  readyPaced.GiveCard(Ids[0],\u0022cottage\u0022); readyPaced.GiveCard(Ids[1],\u0022park\u0022);\r\n  var readyBot=new Bot(Ids[0],readyPaced,readyRounds,BotDifficulty.Normal,16,\r\n   finishBuildingQuickly:()=\u003EreadyPaced.Players[Ids[1]].Hand.Count==0 || readyRounds.IsBuildReady(Ids[1]));\r\n  readyBot.Update(0);readyRounds.Ready(Ids[1],0.1);\r\n  for(var n=6;n\u003C90 \u0026\u0026 readyPaced.Phase==MatchPhase.Build;n\u002B\u002B) readyBot.Update(n/60.0);\r\n  Check(readyPaced.Players[Ids[0]].Hand.Count==0 \u0026\u0026 readyPaced.Phase==MatchPhase.Payout \u0026\u0026 readyPaced.Players[Ids[1]].Hand.Count==1,\u0022human readiness accelerates remaining bot builds even when the human keeps a card\u0022);\r\n  var scoring=new MatchState(9,9,101,endSettings); scoring.AddPlayer(Ids[0],\u0022A\u0022); scoring.AddPlayer(Ids[1],\u0022B\u0022);\r\n  for (var i=0;i\u003C9;i\u002B\u002B) { scoring.SeedStreet(i,4); if (i!=4) scoring.SeedStreet(4,i); }\r\n  scoring.SetPhase(MatchPhase.Build,8,40);\r\n  void Place(Guid owner,string card,int x,int y)\r\n  {\r\n   if (!scoring.GiveCard(owner,card) || !scoring.TryPlace(owner,card,x,y).Success) throw new InvalidOperationException(\u0022End-score fixture placement failed: \u0022\u002Bcard);\r\n  }\r\n  Place(Ids[0],\u0022house\u0022,2,3); Place(Ids[1],\u0022house\u0022,3,2); Place(Ids[0],\u0022factory\u0022,3,3);\r\n  Place(Ids[0],\u0022park\u0022,6,3); Place(Ids[0],\u0022cafe\u0022,5,3); Place(Ids[0],\u0022museum\u0022,6,5); Place(Ids[0],\u0022monument\u0022,0,0);\r\n  var effects=FinalScoring.CardEffects(scoring);\r\n  Check(effects.Where(l=\u003El.Player==Ids[0] \u0026\u0026 l.Reason==\u0022#rules.final_scoring.factory_pollution\u0022).Sum(l=\u003El.Vp)==-1 \u0026\u0026 effects.Where(l=\u003El.Player==Ids[1] \u0026\u0026 l.Reason==\u0022#rules.final_scoring.factory_pollution\u0022).Sum(l=\u003El.Vp)==-1 \u0026\u0026\r\n   effects.Single(l=\u003El.Reason==\u0022#cards.monument.name\u0022).Vp==6 \u0026\u0026 effects.Single(l=\u003El.Reason==\u0022#cards.cafe.name\u0022).Vp==2 \u0026\u0026 effects.Single(l=\u003El.Reason==\u0022#cards.museum.name\u0022).Vp==2,\r\n   \u0022end-card scoring accounts for Monument, park-adjacent Cafe, Culture Museum and pollution on both owners\u0022);\r\n  var scored=FinalScoring.Calculate(scoring);\r\n  Check(Ids.Take(2).All(id=\u003Eeffects.Where(l=\u003El.Player==id).SequenceEqual(scored.Players.Single(p=\u003Ep.Player==id).Lines.Where(l=\u003El.Source!=null))),\u0022end-card helper agrees with the final score breakdown\u0022);\r\n  var replacementState=new MatchState(3,3,778,new MatchSettings {EnableGoals=false,EnableContracts=false,DefaultStreets=0,DefaultCommons=0});\r\n  replacementState.AddPlayer(Ids[0],\u0022Renovator\u0022); replacementState.SeedStreet(1,1); replacementState.SetPhase(MatchPhase.Build,1,40);\r\n  for(var y=0;y\u003C3;y\u002B\u002B) for(var x=0;x\u003C3;x\u002B\u002B) if(replacementState.Get(x,y)==null) { replacementState.GiveCard(Ids[0],\u0022park\u0022); replacementState.TryPlace(Ids[0],\u0022park\u0022,x,y); }\r\n  replacementState.SetPhase(MatchPhase.Build,8,40); replacementState.GiveCard(Ids[0],\u0022renovation\u0022); replacementState.GiveCard(Ids[0],\u0022monument\u0022);\r\n  var replacementBot=new Bot(Ids[0],replacementState,new RoundMachine(replacementState),BotDifficulty.Hard,12);\r\n  for(var n=0;n\u003C360 \u0026\u0026 replacementState.Players[Ids[0]].Hand.Count\u003E0;n\u002B\u002B) replacementBot.Update(n/60.0);\r\n  Check(replacementState.Cells().Count()==9 \u0026\u0026 replacementState.Cells().Count(c=\u003Ec.CardId==\u0022monument\u0022)==1 \u0026\u0026 replacementState.Players[Ids[0]].Hand.Count==0,\u0022bot renovates a full board into a useful end-game building through shared rules\u0022);\r\n  Check(FinalScoring.CardEffects(replacementState).Single(e=\u003Ee.Reason==\u0022#cards.monument.name\u0022).Vp==6,\u0022bot replacement retains the actual six-VP end-game effect\u0022);\r\n  var replay=Play(2,9,144,out _); var replayAgain=Play(2,9,144,out _);\r\n  Check(replay==replayAgain,\u0022identical seeds reproduce full bot match decisions and scores\u0022);\r\n  Play(3,9,221,out _); Play(4,9,332,out _); Play(5,17,334,out _);\r\n  var stress=Play(4,18,443,out var duration);\r\n  Check(!string.IsNullOrEmpty(stress) \u0026\u0026 duration\u003C60,$\u0022four bots finish an 18\u00D718 eight-round stress match in {duration:0.00}s\u0022);\r\n  return passed.AsReadOnly();\r\n }\r\n}\r\n\r\n"},{"Ident":"degg.city_sim","Path":"Game/SoloSetupPreferences.cs","FileName":"SoloSetupPreferences.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":443612,"IsPrivate":false,"Code":"using CitySim.Ai;\r\n\r\nnamespace CitySim;\r\n\r\n/// \u003Csummary\u003ESetup preferences are separate from audio/display preferences and match rules.\u003C/summary\u003E\r\npublic static class SoloSetupPreferences\r\n{\r\n    const string Key=\u0022citysim.solo-setup.v1\u0022;\r\n    public static SoloSetupData Load()\r\n    {\r\n        SoloSetupData saved=null;\r\n        try { saved=Game.Cookies.Get\u003CSoloSetupData\u003E(Key,null); }\r\n        catch(Exception e) { Log.Warning($\u0022Could not load game setup: {e.Message}\u0022); }\r\n        return SoloSetup.Normalize(saved,new Random().Next());\r\n    }\r\n    public static void Save(SoloSetupData value)\r\n    {\r\n        try { Game.Cookies.Set(Key,value); }\r\n        catch(Exception e) { Log.Warning($\u0022Could not save game setup: {e.Message}\u0022); }\r\n    }\r\n}\r\n"},{"Ident":"degg.city_sim","Path":"Game/TabletopAudio.cs","FileName":"TabletopAudio.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":443612,"IsPrivate":false,"Code":"namespace CitySim;\r\n\r\n/// \u003Csummary\u003ELocal tabletop feedback. Gains are relative to the saved effects volume.\u003C/summary\u003E\r\npublic static class TabletopAudio\r\n{\r\n static float lastCard = -100, lastMoney = -100;\r\n static void Play(string family, int variants, float volume, float pitch = 1f)\r\n {\r\n  if (PlayerPreferences.EffectsVolume \u003C= 0) return;\r\n  Sfx.Play($\u0022sounds/tabletop/{family}-{Game.Random.Int(1,variants)}.sound\u0022,Vector3.Zero,pitch,volume);\r\n }\r\n public static void Click() =\u003E Play(\u0022click\u0022,3,0.22f);\r\n public static void Switch() =\u003E Play(\u0022switch\u0022,1,0.24f);\r\n public static void Card()\r\n {\r\n  if (Time.Now \u003E= lastCard \u0026\u0026 Time.Now-lastCard \u003C 0.10f) return;\r\n  lastCard=Time.Now; Play(\u0022card-slide\u0022,3,0.32f);\r\n }\r\n public static void Place(float pitch = 1f) =\u003E Play(\u0022card-place\u0022,3,0.55f,pitch);\r\n public static void Purchase() =\u003E Play(\u0022chip-lay\u0022,3,0.5f);\r\n public static void Income(float pitch = 1f)\r\n {\r\n  if (Time.Now \u003E= lastMoney \u0026\u0026 Time.Now-lastMoney \u003C 0.08f) return;\r\n  lastMoney=Time.Now; Play(\u0022chips-stack\u0022,3,0.42f,pitch);\r\n }\r\n public static void Fan() =\u003E Play(\u0022card-fan\u0022,1,0.35f);\r\n}\r\n"},{"Ident":"degg.city_sim","Path":"Match/BuilderChecks.cs","FileName":"BuilderChecks.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":443612,"IsPrivate":false,"Code":"using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing System.Text.Json;\r\nusing CitySim.Cards;\r\n\r\nnamespace CitySim.Match;\r\n\r\npublic static class BuilderChecks\r\n{\r\n    static readonly Guid A=new(\u002211111111-1111-1111-1111-111111111111\u0022), B=new(\u002222222222-2222-2222-2222-222222222222\u0022);\r\n    sealed class CountingRandom : Random { public int Calls; public override int Next(int maxValue) { Calls\u002B\u002B; return 0; } }\r\n    public static IReadOnlyList\u003Cstring\u003E Run()\r\n    {\r\n        var passed=new List\u003Cstring\u003E();\r\n        void Check(bool ok,string name) { if (!ok) throw new InvalidOperationException(name); passed.Add(\u0022PASS \u0022\u002Bname); }\r\n        var settings=new MatchSettings { EnableAuctions=false, EnableContracts=false, DefaultStreets=0, DefaultCommons=0, StubBidSeconds=1, BuildSeconds=30, PayoutSeconds=1 };\r\n        var s=new MatchState(12,12,123,settings); s.AddPlayer(A,\u0022A\u0022); s.AddPlayer(B,\u0022B\u0022);\r\n        for(var x=0;x\u003C12;x\u002B\u002B) s.SeedStreet(x,6);\r\n        var rounds=new RoundMachine(s); rounds.Start(0); rounds.Tick(1);\r\n        void Place(MatchState state,Guid owner,string id,int x) { if (!state.GiveCard(owner,id) || !state.TryPlace(owner,id,x,5).Success) throw new InvalidOperationException(\u0022Setup \u0022\u002Bid); }\r\n        Check(!s.ChooseBuilderDefault(A,true),\u0022Builder preference requires an owned Yard\u0022);\r\n        Place(s,A,\u0022warehouse\u0022,0); Place(s,A,\u0022builders-yard\u0022,1); Place(s,B,\u0022builders-yard\u0022,8);\r\n        Check(!s.BuilderCommon(A) \u0026\u0026 !s.BuilderCommon(B),\u0022Builders\u0027 Yard defaults to Street for each player\u0022);\r\n        Check(s.ChooseBuilderDefault(A,true) \u0026\u0026 s.BuilderCommon(A) \u0026\u0026 !s.BuilderCommon(B),\u0022Builder choices are owner-specific\u0022);\r\n        var matchId=Guid.NewGuid();\r\n        var projectedA=MatchProjection.For(s,rounds,A,matchId,1,1);\r\n        var projectedB=MatchProjection.For(s,rounds,B,matchId,1,1);\r\n        var spectator=MatchProjection.For(s,rounds,Guid.Empty,matchId,1,1);\r\n        Check(projectedA.Own.BuilderCommon \u0026\u0026 !projectedB.Own.BuilderCommon \u0026\u0026 spectator.Own==null \u0026\u0026\r\n            JsonSerializer.Serialize(projectedA.Public)==JsonSerializer.Serialize(projectedB.Public) \u0026\u0026\r\n            !JsonSerializer.Serialize(projectedA.Public).Contains(\u0022BuilderCommon\u0022),\u0022Builder preference is visible only in the owner\u0027s projection\u0022);\r\n        var client=new MatchReplica();\r\n        Check(client.Apply(A,projectedA) \u0026\u0026 client.State.BuilderCommon(A) \u0026\u0026 !client.State.BuilderCommon(B),\u0022replica loads only its owner\u0027s Builder preference\u0022);\r\n        var authority=new MatchAuthority(matchId,s,rounds);\r\n        MatchCommand Request(long sequence,int amount) =\u003E new(matchId,sequence,s.Round,s.Phase,MatchCommandKind.BuilderDefault,Amount:amount);\r\n        Check(!authority.Execute(Guid.Empty,Request(1,0),1).Accepted \u0026\u0026 !authority.Execute(A,Request(1,2),1).Accepted \u0026\u0026 s.BuilderCommon(A),\u0022Builder command rejects unseated callers and invalid values\u0022);\r\n        Check(authority.Execute(A,Request(2,0),1).Accepted \u0026\u0026 !s.BuilderCommon(A) \u0026\u0026 !authority.Execute(A,Request(2,1),1).Accepted \u0026\u0026 !s.BuilderCommon(A),\u0022Builder command accepts owner choice and rejects replay\u0022);\r\n        Check(authority.Execute(B,Request(1,1),1).Accepted \u0026\u0026 !s.BuilderCommon(A) \u0026\u0026 s.BuilderCommon(B),\u0022authenticated Builder command only changes its caller\u0027s choice\u0022);\r\n        Check(authority.Execute(A,Request(3,1),1).Accepted \u0026\u0026 authority.Execute(B,Request(2,0),1).Accepted,\u0022owner choices can be changed independently\u0022);\r\n        var wh=s.Get(0,5); s.GiveCard(A,\u0022street\u0022); var before=s.Players[A].Coins;\r\n        for(var i=0;i\u003C3;i\u002B\u002B) PlacementScoring.Preview(s,\u0022street\u0022,2,5,A);\r\n        Check(s.TriggerUses(wh.Order,1)==0,\u0022Warehouse previews do not consume its cap\u0022);\r\n        Check(!s.TryPlace(A,\u0022street\u0022,0,5).Success \u0026\u0026 s.TriggerUses(wh.Order,1)==0,\u0022rejected Street does not consume Warehouse cap\u0022);\r\n        Check(s.TryPlace(A,\u0022street\u0022,2,5).Success \u0026\u0026 s.Players[A].Coins==before\u002B1,\u0022Warehouse pays for first Street placement\u0022);\r\n        Place(s,A,\u0022street\u0022,3); Check(s.Players[A].Coins==before\u002B1,\u0022Warehouse pays at most once per round\u0022);\r\n        var stateCopy=JsonSerializer.Deserialize\u003CMatchCheckpoint\u003E(JsonSerializer.Serialize(s.Capture()));\r\n        bool Bad(BuilderChoice[] choices) { try { MatchState.Restore(stateCopy with { BuilderChoices=choices }); return false; } catch (ArgumentException) { return true; } }\r\n        Check(Bad(new BuilderChoice[] { null }) \u0026\u0026 Bad(new[] { new BuilderChoice(Guid.NewGuid(),true) }) \u0026\u0026\r\n            Bad(new[] { new BuilderChoice(A,true),new BuilderChoice(A,false) }),\u0022Builder checkpoint rejects null, unknown and duplicate preferences\u0022);\r\n        var withoutYard=MatchState.Restore(stateCopy with { Cells=stateCopy.Cells.Where(c =\u003E !(c.Owner==A \u0026\u0026 c.CardId==\u0022builders-yard\u0022)).ToArray() });\r\n        Check(withoutYard.BuilderCommon(A),\u0022Builder checkpoint retains preference after its Yard is removed\u0022);\r\n        var roundCopy=JsonSerializer.Deserialize\u003CRoundCheckpoint\u003E(JsonSerializer.Serialize(rounds.Capture()));\r\n        var restored=MatchState.Restore(stateCopy); var replay=RoundMachine.Restore(restored,roundCopy);\r\n        Check(restored.BuilderCommon(A) \u0026\u0026 !restored.BuilderCommon(B),\u0022Builder preference survives checkpoint restore\u0022);\r\n        rounds.Ready(A,2); rounds.Ready(B,2); replay.Ready(A,2); replay.Ready(B,2); rounds.Tick(3); replay.Tick(3);\r\n        Check(s.Round==2 \u0026\u0026 restored.Round==2 \u0026\u0026 s.Players[A].Hand.SequenceEqual(restored.Players[A].Hand) \u0026\u0026 s.Players[B].Hand.SequenceEqual(restored.Players[B].Hand),\u0022Builder extra draws replay identically after RNG and choice restore\u0022);\r\n        Check(s.Players[A].Hand.Count==2 \u0026\u0026 s.Players[A].Hand[0]==\u0022street\u0022 \u0026\u0026 CardLibrary.Get(s.Players[A].Hand[1]).Rarity==Rarity.Common \u0026\u0026 s.Players[B].Hand.Count==1 \u0026\u0026 CardLibrary.Get(s.Players[B].Hand[0]).Rarity==Rarity.Common,\u0022Warehouse grants a Street and each Yard grants a random Common regardless of choice\u0022);\r\n        rounds.Tick(4); Check(s.ChooseBuilderDefault(A,false) \u0026\u0026 !s.BuilderCommon(A),\u0022Yard choice can change for the following round\u0022);\r\n        var nextCoins=s.Players[A].Coins; Check(s.TryPlace(A,\u0022street\u0022,4,5).Success \u0026\u0026 s.Players[A].Coins==nextCoins\u002B1,\u0022Warehouse cap resets next round\u0022);\r\n        while(s.Players[A].Hand.Count\u003Csettings.HandLimit) s.GiveCard(A,\u0022street\u0022);\r\n        while(s.Players[B].Hand.Count\u003Csettings.HandLimit) s.GiveCard(B,\u0022street\u0022);\n        var random=new CountingRandom(); s.ChooseBuilderDefault(A,true); var full=s.Players[A].Hand.Count;\r\n        s.BuilderDefaults(random);\r\n        Check(s.Players[A].Hand.Count==full \u0026\u0026 random.Calls==0,\u0022full hand skips Builder cards and consumes no Common RNG\u0022);\r\n        return passed.AsReadOnly();\r\n    }\r\n}\r\n"},{"Ident":"degg.city_sim","Path":"Match/CardInteractions.cs","FileName":"CardInteractions.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":443612,"IsPrivate":false,"Code":"using System;\r\nusing CitySim.Cards;\r\n\r\nnamespace CitySim.Match;\r\n\r\npublic enum InteractionKind { None, Residential, House, Shop, Market, Cafe, Farm, Park, Lake, Building, Road, Home, Trade, Nature, Industry, Culture, Civic, Tagged, Card }\r\npublic sealed record InteractionTarget(string Label, InteractionKind Kind, bool OwnOnly = false, string CardId = null);\r\n\r\n/// \u003Csummary\u003EMatches the named category, not a guarantee of range, ownership, or an active effect.\u003C/summary\u003E\r\npublic static class InteractionMatcher\r\n{\r\n    public static bool Matches(InteractionTarget target, BoardCell cell, Guid viewer)\r\n    {\r\n        if (target == null || cell == null || target.OwnOnly \u0026\u0026 cell.Owner != viewer) return false;\r\n        var card = CardLibrary.Get(cell.CardId);\r\n        return target.Kind switch {\r\n            InteractionKind.Card =\u003E cell.CardId == target.CardId,\r\n            InteractionKind.Residential =\u003E Match.BoardRules.IsHome(cell),\r\n            InteractionKind.House =\u003E cell.CardId == \u0022house\u0022,\r\n            InteractionKind.Shop =\u003E cell.CardId == \u0022shop\u0022,\r\n            InteractionKind.Market =\u003E cell.CardId == \u0022market\u0022,\r\n            InteractionKind.Cafe =\u003E cell.CardId == \u0022cafe\u0022,\r\n            InteractionKind.Farm =\u003E Match.BoardRules.IsFarm(cell),\r\n            InteractionKind.Park =\u003E Match.BoardRules.IsPark(cell),\r\n            InteractionKind.Lake =\u003E cell.CardId == \u0022lake\u0022,\r\n            InteractionKind.Building =\u003E card.Type is not (CardType.Road or CardType.Event),\r\n            InteractionKind.Road =\u003E card.Type == CardType.Road,\r\n            InteractionKind.Home =\u003E card.Tags.HasFlag(Tag.Home),\r\n            InteractionKind.Trade =\u003E card.Tags.HasFlag(Tag.Trade),\r\n            InteractionKind.Nature =\u003E card.Tags.HasFlag(Tag.Nature),\r\n            InteractionKind.Industry =\u003E card.Tags.HasFlag(Tag.Industry),\r\n            InteractionKind.Culture =\u003E card.Tags.HasFlag(Tag.Culture),\r\n            InteractionKind.Civic =\u003E card.Tags.HasFlag(Tag.Civic),\r\n            InteractionKind.Tagged =\u003E card.Tags != Tag.None,\r\n            _ =\u003E false\r\n        };\r\n    }\r\n}\r\n"},{"Ident":"degg.city_sim","Path":"Match/MatchState.cs","FileName":"MatchState.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":443612,"IsPrivate":false,"Code":"using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing CitySim.Cards;\n\nnamespace CitySim.Match;\n\npublic sealed record BoardCell( int X, int Y, string CardId, Guid Owner, int Order, int PlacedRound, int Age = 0 );\npublic sealed record TileClaim(int X,int Y,Guid Owner,int ExpiresRound);\npublic sealed record RoundActivity(Guid Player,int Buildings,bool RushHour,int RushHourCount = 0);\n\npublic sealed class MatchPlayer\n{\n\treadonly List\u003Cstring\u003E hand = new();\n\tpublic Guid Id { get; }\n\tpublic string Name { get; }\n\tpublic int Coins { get; internal set; }\n\tpublic int Vp { get; internal set; }\n\tpublic long HandVersion { get; internal set; }\n\tpublic IReadOnlyList\u003Cstring\u003E Hand =\u003E hand.AsReadOnly();\n\tinternal List\u003Cstring\u003E MutableHand =\u003E hand;\n\tinternal MatchPlayer( Guid id, string name, int coins ) { Id = id; Name = name; Coins = coins; }\n}\n\n/// \u003Csummary\u003EAuthoritative rules data. Contains no engine types or scene references.\u003C/summary\u003E\npublic sealed partial class MatchState\n{\n\treadonly BoardCell[,] board;\n\treadonly Dictionary\u003CGuid, MatchPlayer\u003E players = new();\n\treadonly Dictionary\u003C(int,int),TileClaim\u003E claims = new();\n\tint order;\n\treadonly Dictionary\u003C(int SourceOrder,int Round),int\u003E triggerUsage = new();\n\treadonly Dictionary\u003CGuid,int\u003E roundBuildings = new();\n\treadonly Dictionary\u003CGuid,int\u003E rushHours = new();\n\tpublic int BuildingsPlacedThisRound(Guid player) =\u003E roundBuildings.GetValueOrDefault(player);\n\tpublic int RushHourCount(Guid player) =\u003E rushHours.GetValueOrDefault(player);\n\tpublic bool RushHourActive(Guid player) =\u003E RushHourCount(player) \u003E 0;\n\tinternal RoundActivity[] ActivitySnapshot() =\u003E players.Keys.Select(id=\u003Enew RoundActivity(id,BuildingsPlacedThisRound(id),RushHourActive(id),RushHourCount(id))).ToArray();\n\tvoid LoadActivity(IEnumerable\u003CRoundActivity\u003E entries)\n\t{\n\t\troundBuildings.Clear(); rushHours.Clear();\n\t\tforeach (var item in entries ?? Array.Empty\u003CRoundActivity\u003E()) { roundBuildings[item.Player]=item.Buildings; if (item.RushHourCount \u003E 0 || item.RushHour) rushHours[item.Player] = Math.Max(item.RushHourCount, item.RushHour ? 1 : 0); }\n\t}\n\tinternal int TriggerUses(int sourceOrder,int round) =\u003E triggerUsage.GetValueOrDefault((sourceOrder,round));\n\tinternal TriggerUsageCheckpoint[] TriggerUsageSnapshot() =\u003E triggerUsage.OrderBy(p =\u003E p.Key.SourceOrder).ThenBy(p =\u003E p.Key.Round).Select(p =\u003E new TriggerUsageCheckpoint(p.Key.SourceOrder,p.Key.Round,p.Value)).ToArray();\n\tpublic int Width { get; }\n\tpublic int Height { get; }\n\tpublic int Seed { get; }\n\tpublic int Round { get; private set; }\n\tpublic MatchPhase Phase { get; private set; } = MatchPhase.Lobby;\n\tpublic double PhaseEndsAt { get; private set; }\n\tpublic MatchSettings Settings { get; }\n\tpublic MatchEvents Events { get; } = new();\n\tpublic IReadOnlyList\u003CTileClaim\u003E Claims =\u003E claims.Values.ToList().AsReadOnly();\n\tpublic EndScore FinalScore { get; internal set; }\n\tpublic Goals Goals { get; }\n\tpublic IReadOnlyList\u003CContractReveal\u003E ContractReveals { get; internal set; } = Array.Empty\u003CContractReveal\u003E();\n\tpublic int LastPaidRound { get; internal set; }\n\tinternal int NextPlacementOrder =\u003E order\u002B1;\n\tinternal MatchCheckpoint Capture() =\u003E new(2,Width,Height,Seed,Settings,Round,Phase,PhaseEndsAt,order,LastPaidRound,Cells().ToArray(),Claims.ToArray(),\n\t\tplayers.Values.Select(p =\u003E new PlayerCheckpoint(p.Id,p.Name,p.Coins,p.Vp,p.HandVersion,p.Hand.ToArray())).ToArray(),Goals.Capture(),ContractReveals.ToArray(),FinalScore,TriggerUsageSnapshot(),ActivitySnapshot(),BuilderChoiceSnapshot());\n\tinternal static MatchState Restore(MatchCheckpoint data)\n\t{\n\t\tif (data.Version != 2) throw new ArgumentException(\u0022Unsupported match checkpoint version.\u0022);\n\t\tvar usage = data.TriggerUsage ?? Array.Empty\u003CTriggerUsageCheckpoint\u003E();\n\t\tif (usage.Any(u =\u003E u == null || u.SourceOrder \u003C= 0 || u.SourceOrder \u003E data.PlacementOrder || u.Round \u003C= 0 || u.Round \u003E data.Round || u.Count \u003C= 0 || u.Count \u003E 2) ||\n\t\t\tusage.GroupBy(u =\u003E (u.SourceOrder,u.Round)).Any(g =\u003E g.Count() != 1))\n\t\t\tthrow new ArgumentException(\u0022Invalid placement trigger usage.\u0022);\n\t\tvar s = new MatchState(data.Width,data.Height,data.Seed,data.Settings) { Round=data.Round,Phase=data.Phase,PhaseEndsAt=data.PhaseEndsAt,order=data.PlacementOrder,LastPaidRound=data.LastPaidRound,FinalScore=data.FinalScore,ContractReveals=Array.AsReadOnly(data.ContractReveals) };\n\t\tforeach (var p in data.Players) { var player = s.AddPlayer(p.Id,p.Name); player.Coins=p.Coins; player.Vp=p.Vp; player.HandVersion=p.HandVersion; player.MutableHand.AddRange(p.Hand); }\n\t\tforeach (var cell in data.Cells) s.board[cell.X,cell.Y] = cell;\n\t\tforeach (var claim in data.Claims) s.claims[(claim.X,claim.Y)] = claim;\n\t\tvar activity=data.Activity ?? Array.Empty\u003CRoundActivity\u003E();\n\t\tif (activity.Any(a=\u003Ea==null || !s.players.ContainsKey(a.Player) || a.Buildings\u003C0 || a.Buildings\u003Edata.PlacementOrder || a.RushHourCount\u003C0) || activity.Select(a=\u003Ea.Player).Distinct().Count()!=activity.Length || activity.Sum(a=\u003E(long)a.Buildings)\u003Edata.PlacementOrder) throw new ArgumentException(\u0022Invalid round activity.\u0022);\n\t\ts.LoadActivity(activity); s.LoadBuilderChoices(data.BuilderChoices); s.Goals.Restore(data.Goals); foreach (var entry in usage) s.triggerUsage[(entry.SourceOrder,entry.Round)] = entry.Count; return s;\n\t}\n\tinternal void ApplyReplica(PublicMatchSnapshot data,OwnerMatchSnapshot own)\n\t{\n\t\tif (data.Width != Width || data.Height != Height) throw new ArgumentException(\u0022Replica board dimensions changed.\u0022);\n\t\tvar resourceChanges = new List\u003C(ResourceChange Coins,ResourceChange Vp)\u003E();\n\t\tvar handChanges = new List\u003CGuid\u003E(); var placed = new List\u003CBoardCell\u003E(); var updated = new List\u003CBoardCell\u003E(); var replaced = new List\u003CBoardReplacement\u003E();\n\t\tvar previousPhase = (Phase,Round,PhaseEndsAt); var wasScored = FinalScore != null;\n\t\tforeach (var source in data.Players)\n\t\t{\n\t\t\tif (!players.TryGetValue(source.Id,out var player)) player = AddPlayer(source.Id,source.Name);\n\t\t\tresourceChanges.Add((new(source.Id,player.Coins,source.Coins),new(source.Id,player.Vp,source.Vp)));\n\t\t\tplayer.Coins=source.Coins; player.Vp=source.Vp;\n\t\t\tvar hand = own?.Owner == source.Id ? own.Hand : Array.Empty\u003Cstring\u003E();\n\t\t\tif (!player.Hand.SequenceEqual(hand)) handChanges.Add(source.Id);\n\t\t\tplayer.MutableHand.Clear(); player.MutableHand.AddRange(hand); player.HandVersion=own?.Owner == source.Id ? own.HandVersion : 0;\n\t\t}\n\t\tforeach (var cell in data.Cells)\n\t\t{\n\t\t\tvar old = board[cell.X,cell.Y];\n\t\t\tif (old == null) placed.Add(cell); else if (old.Order != cell.Order || old.CardId != cell.CardId) { replaced.Add(new(old,cell)); placed.Add(cell); } else if (old != cell) updated.Add(cell);\n\t\t\tboard[cell.X,cell.Y]=cell;\n\t\t}\n\t\torder=data.Cells.Select(c =\u003E c.Order).DefaultIfEmpty(0).Max();\n\t\tclaims.Clear(); foreach (var claim in data.Claims) claims[(claim.X,claim.Y)]=claim;\n\t\tGoals.ApplyPublic(data.Goals,data.GoalHistory); ContractReveals=Array.AsReadOnly(data.ContractReveals); FinalScore=data.FinalScore;\n\t\tPhase=data.Phase; Round=data.Round; PhaseEndsAt=data.PhaseEndsAt; LoadActivity(data.Activity);\n\t\tLoadBuilderChoices(own == null ? Array.Empty\u003CBuilderChoice\u003E() : new[] { new BuilderChoice(own.Owner,own.BuilderCommon) });\n\t\tforeach (var change in resourceChanges) { if (change.Coins.Previous != change.Coins.Current) Events.Coins(change.Coins); if (change.Vp.Previous != change.Vp.Current) Events.Vp(change.Vp); }\n\t\tforeach (var id in handChanges) Events.Hand(id);\n\t\tforeach (var change in replaced) { Events.Replaced(change); if (CardLibrary.Get(change.Before.CardId).EffectTray) Events.Tray(change.Before.Owner); }\n\t\tforeach (var cell in placed) { Events.Placed(cell); if (CardLibrary.Get(cell.CardId).EffectTray) Events.Tray(cell.Owner); }\n\t\tforeach (var cell in updated) Events.Updated(cell);\n\t\tEvents.Claims(); Events.GoalsChanged();\n\t\tif (previousPhase != (Phase,Round,PhaseEndsAt)) Events.Phase(new(Phase,Round,PhaseEndsAt));\n\t\tif (!wasScored \u0026\u0026 FinalScore != null) Events.Scored(FinalScore);\n\t}\n\tpublic IReadOnlyDictionary\u003CGuid, MatchPlayer\u003E Players =\u003E new System.Collections.ObjectModel.ReadOnlyDictionary\u003CGuid, MatchPlayer\u003E( players );\n\n\tpublic MatchState( int width, int height, int seed = 0, MatchSettings settings = null )\n\t{\n\t\tif ( width \u003C= 0 || height \u003C= 0 ) throw new ArgumentOutOfRangeException(nameof(width));\n\t\tWidth = width; Height = height; Seed = seed; Settings = settings ?? new();\n\t\tif ( Settings.HandLimit \u003C 1 || Settings.StartingCoins \u003C 0 || Settings.RareBuildCost \u003C 0 || Settings.LegendaryBuildCost \u003C 0 || Settings.Rounds \u003C 1 ||\n\t\t\tSettings.BuildSeconds \u003C= 0 || !double.IsFinite(Settings.BuildSeconds) || !double.IsFinite(Settings.HospitalEmergencyFraction) || Settings.HospitalEmergencyFraction \u003C 0 || Settings.HospitalEmergencyFraction \u003E 1 ||\n\t\t\tSettings.PayoutSeconds \u003C= 0 || !double.IsFinite(Settings.PayoutSeconds) ||\n\t\t\tSettings.StubBidSeconds \u003C 0 || !double.IsFinite(Settings.StubBidSeconds) ||\n\t\t\tSettings.MiniAuctionSeconds \u003C= 0 || !double.IsFinite(Settings.MiniAuctionSeconds) ||\n\t\t\tSettings.AuctionResolveSeconds \u003C 0 || !double.IsFinite(Settings.AuctionResolveSeconds) ||\n\t\t\tSettings.HandTarget \u003C 1 || Settings.HandTarget \u003E Settings.HandLimit || Settings.MiniAuctionCap \u003C 1 || Settings.ExtraLots \u003C 1 ||\n\t\t\tSettings.CoinsPerEndVp \u003C 1 ||\n\t\t\tSettings.PublicGoalCount \u003C 1 || Settings.PublicGoalCount \u003E GoalLibrary.All.Count || Settings.ContractChoices \u003C 1 || Settings.ContractChoices \u003E Contracts.All.Count || Settings.GoalVp \u003C 0 || Settings.TiedGoalVp \u003C 0 ||\n\t\t\tSettings.StubOpponentBid \u003C 0 || Settings.StubThinkSeconds \u003C 0 || !double.IsFinite(Settings.StubThinkSeconds) ||\n\t\t\tSettings.AiFrameBudgetMilliseconds \u003C= 0 || !double.IsFinite(Settings.AiFrameBudgetMilliseconds) ||\n\t\t\tSettings.Rarities.Count != 4 || Settings.Rarities.Select(r =\u003E r.Rarity).Distinct().Count() != 4 || Settings.Rarities.Any(r =\u003E r.MinimumBid \u003C 1 || r.RevealWeight \u003C 0) ) throw new ArgumentException(\u0022Invalid match settings.\u0022);\n\t\tboard = new BoardCell[width,height];\n\t\tGoals = new Goals(this);\n\t}\n\n\tpublic MatchPlayer AddPlayer( Guid id, string name )\n\t{\n\t\tvar player = new MatchPlayer( id, name, Settings.StartingCoins );\n\t\tplayers.Add( id, player );\n\t\treturn player;\n\t}\n\n\tpublic bool InBounds( int x, int y ) =\u003E x \u003E= 0 \u0026\u0026 y \u003E= 0 \u0026\u0026 x \u003C Width \u0026\u0026 y \u003C Height;\n\tpublic BoardCell Get( int x, int y ) =\u003E InBounds(x,y) ? board[x,y] : null;\n\tpublic IEnumerable\u003CBoardCell\u003E Cells()\n\t{\n\t\tfor ( var y = 0; y \u003C Height; y\u002B\u002B )\n\t\tfor ( var x = 0; x \u003C Width; x\u002B\u002B )\n\t\t\tif ( board[x,y] is { } cell ) yield return cell;\n\t}\n\tpublic IEnumerable\u003CBoardCell\u003E Neighbours( int x, int y )\n\t{\n\t\tforeach ( var offset in new[] { (0,-1), (1,0), (0,1), (-1,0) } )\n\t\t\tif ( Get(x\u002Boffset.Item1,y\u002Boffset.Item2) is { } cell ) yield return cell;\n\t}\n\n\tpublic void SetPhase( MatchPhase phase, int round, double endsAt )\n\t{\n\t\tif ( round \u003C 0 || round \u003E Settings.Rounds || !double.IsFinite(endsAt) ) throw new ArgumentOutOfRangeException(nameof(round));\n\t\tif (round!=Round) { roundBuildings.Clear(); rushHours.Clear(); }\n\t\tPhase = phase; Round = round; PhaseEndsAt = endsAt;\n\t\tif (phase == MatchPhase.RoundStart)\n\t\t{\n\t\t\tvar expired = claims.Values.Where(c =\u003E c.ExpiresRound \u003C= round).ToArray();\n\t\t\tforeach (var claim in expired) claims.Remove((claim.X,claim.Y));\n\t\t\tif (expired.Length \u003E 0) Events.Claims();\n\t\t}\n\t\tEvents.Phase( new(phase,round,endsAt) );\n\t}\n\n\tpublic bool GiveCard( Guid playerId, string cardId )\n\t{\n\t\tif ( !players.TryGetValue(playerId,out var player) || !CardLibrary.TryGet(cardId,out _) || player.Hand.Count \u003E= Settings.HandLimit ) return false;\n\t\tplayer.MutableHand.Add(cardId);\n\t\tHandChanged(playerId);\n\t\tEvents.Received(new(playerId,cardId));\n\t\treturn true;\n\t}\n\n\tpublic string PlacementError( Guid playerId, string cardId, int x, int y )\n\t{\n\t\tif ( Phase != MatchPhase.Build ) return \u0022#rules.match_state.wait_for_the_build_phase\u0022;\n\t\tif ( !players.TryGetValue(playerId,out var player) ) return \u0022#rules.match_state.unknown_player\u0022;\n\t\tif ( !CardLibrary.TryGet(cardId,out var card) ) return \u0022#rules.match_state.unknown_card\u0022;\n\t\tif ( !player.Hand.Contains(cardId) ) return \u0022#rules.match_state.card_is_not_in_your_hand\u0022;\n\t\tif ( card.Type == CardType.Event ) return \u0022#rules.match_state.play_this_event_through_its_effect_rule\u0022;\n\t\tif ( player.Coins \u003C Settings.BuildCost(card) ) return GameText.Message(\u0022rules.match_state.not_enough_coins_to_build_cost\u0022, Settings.BuildCost(card));\n\t\tif ( !InBounds(x,y) ) return \u0022#rules.match_state.outside_the_board\u0022;\n\t\tif ( Get(x,y) != null ) return \u0022#rules.match_state.tile_is_occupied\u0022;\n\t\tif (claims.TryGetValue((x,y),out var claim) \u0026\u0026 claim.Owner != playerId \u0026\u0026 claim.ExpiresRound \u003E Round) return \u0022#rules.match_state.reserved_by_another_player\u0022;\n\t\tif ( card.Id == \u0022street\u0022 \u0026\u0026 x != 0 \u0026\u0026 y != 0 \u0026\u0026 x != Width-1 \u0026\u0026 y != Height-1 \u0026\u0026 !Neighbours(x,y).Any(c =\u003E CardLibrary.Get(c.CardId).Type == CardType.Road) ) return \u0022#rules.match_state.street_needs_an_adjacent_road_or_the_board_edge\u0022;\n\t\tif ( card.NeedsRoad \u0026\u0026 !Neighbours(x,y).Any(c =\u003E CardLibrary.Get(c.CardId).Type == CardType.Road) ) return \u0022#rules.match_state.needs_an_adjacent_road\u0022;\n\t\tforeach (var requirement in card.Effects.Where(e =\u003E e.Hook == EffectHook.Place \u0026\u0026 e.Rule == \u0022minimum-buildings\u0022))\n\t\t\tif (Neighbours(x,y).Count(c =\u003E CardLibrary.Get(c.CardId).Type is not (CardType.Road or CardType.Event)) \u003C requirement.Cap) return GameText.Message(\u0022rules.match_state.needs_at_least_adjacent_buildings\u0022, requirement.Cap);\n\t\tif ( card.OwnershipLimit \u003E 0 \u0026\u0026 Cells().Count(c =\u003E c.Owner == playerId \u0026\u0026 c.CardId == cardId) \u003E= card.OwnershipLimit ) return \u0022#rules.match_state.ownership_limit_reached\u0022;\n\t\treturn null;\n\t}\n\n\tpublic PlacementResult TryPlace( Guid playerId, string cardId, int x, int y )\n\t{\n\t\tvar error = PlacementError(playerId,cardId,x,y);\n\t\tif ( error != null ) { var rejected = new PlacementResult(false,error); Events.Rejected(rejected); return rejected; }\n\t\tvar cell = new BoardCell(x,y,cardId,playerId,\u002B\u002Border,Round);\n\t\tvar effects = PlacementScoring.Calculate(this,cell);\n\t\tplayers[playerId].MutableHand.Remove(cardId);\n\t\tChangeCoins(playerId,-Settings.BuildCost(CardLibrary.Get(cardId)));\n\t\tCommitPlacement(cell,effects);\n\t\treturn new(true,\u0022\u0022,cell);\n\t}\n\tinternal void CommitPlacement(BoardCell cell,PlacementEffects effects,BoardCell replaced=null)\n\t{\n\t\tvar (x,y,cardId,playerId)=(cell.X,cell.Y,cell.CardId,cell.Owner);\n\t\tboard[x,y] = cell;\n\t\tif (CardLibrary.Get(cardId).Type is not (CardType.Road or CardType.Event)) roundBuildings[playerId]=BuildingsPlacedThisRound(playerId)\u002B1;\n\t\tforeach (var award in effects.Awards)\n\t\t{\n\t\t\tif (award.Reason == \u0022#cards.warehouse.name\u0022 \u0026\u0026 award.Source != null)\n\t\t\t{\n\t\t\t\tvar key = (award.Source.Order,Round);\n\t\t\t\ttriggerUsage[key] = triggerUsage.GetValueOrDefault(key)\u002B1;\n\t\t\t}\n\t\t\tApplyAward(award);\n\t\t}\n\t\tif (claims.Remove((x,y))) Events.Claims();\n\t\tHandChanged(playerId);\n\t\tif (replaced != null) { Events.Replaced(new(replaced,cell)); if (CardLibrary.Get(replaced.CardId).EffectTray) Events.Tray(replaced.Owner); }\n\t\tEvents.Placed(cell);\n\t\tEvents.Placement(effects);\n\t\tif (effects.Park != null) Events.Park(effects.Park);\n\t\tif (CardLibrary.Get(cardId).EffectTray) Events.Tray(playerId);\n\t}\n\n\tpublic string CardPlayError(Guid owner,string cardId,int x,int y)\n\t{\n\t\tif (cardId != \u0022land-claim\u0022) return PlacementError(owner,cardId,x,y);\n\t\tif (Phase != MatchPhase.Build) return \u0022#rules.match_state.wait_for_the_build_phase\u0022;\n\t\tif (!players.TryGetValue(owner,out var player) || !player.Hand.Contains(cardId)) return \u0022#rules.match_state.card_is_not_in_your_hand\u0022;\n\t\tif (!InBounds(x,y)) return \u0022#rules.match_state.outside_the_board\u0022;\n\t\tif (Get(x,y) != null) return \u0022#rules.match_state.tile_is_occupied\u0022;\n\t\tif (claims.TryGetValue((x,y),out var claim) \u0026\u0026 claim.ExpiresRound \u003E Round) return \u0022#rules.match_state.tile_is_already_reserved\u0022;\n\t\treturn null;\n\t}\n\tpublic PlacementResult TryClaim(Guid owner,int slot,int x,int y)\n\t{\n\t\tif (!players.TryGetValue(owner,out var player) || slot \u003C 0 || slot \u003E= player.Hand.Count || player.Hand[slot] != \u0022land-claim\u0022) return new(false,\u0022#rules.match_state.select_land_claim_first\u0022);\n\t\tvar error = CardPlayError(owner,\u0022land-claim\u0022,x,y);\n\t\tif (error != null) { var denied = new PlacementResult(false,error); Events.Rejected(denied); return denied; }\n\t\tvar duration = CardLibrary.Get(\u0022land-claim\u0022).Effects.Single(e =\u003E e.Rule == \u0022land-claim\u0022).Amount;\n\t\tclaims[(x,y)] = new(x,y,owner,Round\u002Bduration);\n\t\tplayer.MutableHand.RemoveAt(slot); HandChanged(owner); Events.Claims();\n\t\tEvents.Played(new(owner,\u0022land-claim\u0022,Claim:claims[(x,y)]));\n\t\treturn new(true,\u0022\u0022);\n\t}\n\tinternal void UpdateCell(BoardCell cell) { board[cell.X,cell.Y] = cell; Events.Updated(cell); }\n\tinternal void ApplyAward(RuleAward award) { if (award.Coins != 0) ChangeCoins(award.Player,award.Coins); if (award.Vp != 0) ChangeVp(award.Player,award.Vp); }\n\n\tpublic bool Discard( Guid playerId, int slot )\n\t{\n\t\tif ( Phase != MatchPhase.Build || !players.TryGetValue(playerId,out var player) || slot \u003C 0 || slot \u003E= player.Hand.Count ) return false;\n\t\tvar cardId=player.Hand[slot];\n\t\tplayer.MutableHand.RemoveAt(slot); HandChanged(playerId); Events.Discarded(new(playerId,cardId)); return true;\n\t}\n\n\tpublic bool PlayGrant( Guid playerId, int slot )\n\t{\n\t\treturn players.TryGetValue(playerId,out var player) \u0026\u0026 slot\u003E=0 \u0026\u0026 slot\u003Cplayer.Hand.Count \u0026\u0026 player.Hand[slot]==\u0022grant\u0022 \u0026\u0026 PlayEvent(playerId,slot);\n\t}\n\t/// \u003Csummary\u003EEach Rush Hour played adds a separate bonus for this round.\u003C/summary\u003E\n\tpublic bool PlayEvent(Guid playerId,int slot)\n\t{\n\t\tif (Phase!=MatchPhase.Build || !players.TryGetValue(playerId,out var player) || slot\u003C0 || slot\u003E=player.Hand.Count) return false;\n\t\tvar id=player.Hand[slot]; if (id is not (\u0022grant\u0022 or \u0022ribbon-cutting\u0022 or \u0022rush-hour\u0022)) return false;\n\t\tvar amount=0;\n\t\tif (id==\u0022grant\u0022) amount=CardLibrary.Get(id).Effects.First(e=\u003Ee.Rule==\u0022coins\u0022).Amount;\n\t\tif (id==\u0022ribbon-cutting\u0022) { var effect=CardLibrary.Get(id).Effects.First(e=\u003Ee.Rule==\u0022round-building-vp\u0022); amount=(int)Math.Min(effect.Cap,(long)BuildingsPlacedThisRound(playerId)*effect.Amount); }\n\t\tif (id==\u0022ribbon-cutting\u0022 ? (long)player.Vp\u002Bamount\u003Eint.MaxValue : (long)player.Coins\u002Bamount\u003Eint.MaxValue) return false;\n\t\tif (id==\u0022rush-hour\u0022) rushHours[playerId] = RushHourCount(playerId) \u002B 1;\n\t\tplayer.MutableHand.RemoveAt(slot); if (id==\u0022ribbon-cutting\u0022) ChangeVp(playerId,amount); else ChangeCoins(playerId,amount); HandChanged(playerId); Events.Played(new(playerId,id,Coins:id==\u0022ribbon-cutting\u0022 ? 0 : amount,Vp:id==\u0022ribbon-cutting\u0022 ? amount : 0)); return true;\n\t}\n\tvoid HandChanged(Guid owner) { players[owner].HandVersion\u002B\u002B; Events.Hand(owner); }\n\n\t/// \u003Csummary\u003ESeed scenery before a match. Neutral roads never belong to a player.\u003C/summary\u003E\n\tpublic bool SeedStreet( int x, int y )\n\t{\n\t\tif ( Phase != MatchPhase.Lobby || !InBounds(x,y) || Get(x,y) != null ) return false;\n\t\tvar cell = new BoardCell(x,y,\u0022street\u0022,Guid.Empty,\u002B\u002Border,0);\n\t\tboard[x,y] = cell; Events.Placed(cell); return true;\n\t}\n\n\tpublic bool ChangeCoins( Guid playerId, int delta )\n\t{\n\t\tif ( !players.TryGetValue(playerId,out var p) || (long)p.Coins \u002B delta \u003C 0 || (long)p.Coins \u002B delta \u003E int.MaxValue ) return false;\n\t\tvar previous = p.Coins; p.Coins \u002B= delta;\n\t\tif ( delta != 0 ) Events.Coins(new(playerId,previous,p.Coins));\n\t\treturn true;\n\t}\n\tpublic void ChangeVp( Guid playerId, int delta )\n\t{\n\t\tvar p = players[playerId]; var previous = p.Vp; p.Vp = checked(p.Vp \u002B delta);\n\t\tif ( delta != 0 ) Events.Vp(new(playerId,previous,p.Vp));\n\t}\n}\n"},{"Ident":"degg.city_sim","Path":"Match/SessionCheckpoint.cs","FileName":"SessionCheckpoint.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":443612,"IsPrivate":false,"Code":"using System;\r\nusing System.Linq;\r\n\r\nnamespace CitySim.Match;\r\n\r\ninternal sealed record RecipientRevision(Guid Player,long Revision);\r\ninternal sealed record SessionCheckpoint(int Version,LobbyView Lobby,HostCheckpoint Match,MatchSettings Settings,RecipientRevision[] Recipients=null,SeatAccount[] Seats=null)\r\n{\r\n\tinternal static void Validate(SessionCheckpoint saved)\r\n\t{\r\n\t\tif (saved?.Version!=3 || saved.Lobby==null || saved.Settings==null || saved.Seats==null)\r\n\t\t\tthrow new ArgumentException(\u0022Incomplete session checkpoint.\u0022);\r\n\t\tvar lobby=saved.Lobby;\r\n\t\tif (!Enum.IsDefined(lobby.Phase) || lobby.Members==null || lobby.Revision\u003C0 || lobby.Members.Any(m=\u003Em==null || m.Id==Guid.Empty) ||\r\n\t\t\tlobby.Members.Select(m=\u003Em.Id).Distinct().Count()!=lobby.Members.Length)\r\n\t\t\tthrow new ArgumentException(\u0022Invalid lobby checkpoint.\u0022);\r\n\t\tif ((lobby.Phase==LobbyPhase.Waiting)!=(saved.Match==null)) throw new ArgumentException(\u0022Lobby and match checkpoint disagree.\u0022);\r\n\t\tif (saved.Recipients!=null \u0026\u0026 (saved.Recipients.Any(r=\u003Er==null || r.Player==Guid.Empty || r.Revision\u003C0) ||\r\n\t\t\tsaved.Recipients.Select(r=\u003Er.Player).Distinct().Count()!=saved.Recipients.Length)) throw new ArgumentException(\u0022Invalid recipient revisions.\u0022);\r\n\t\tif (saved.Match is not { } match) return;\r\n\t\tif (match.Version!=2 || match.MatchId==Guid.Empty || match.Revision\u003C0 || !double.IsFinite(match.Clock) || match.Clock\u003C0 ||\r\n\t\t\tmatch.Match==null || match.Match.Settings==null || match.Rounds==null || match.Sequences==null || !double.IsFinite(match.Match.PhaseEndsAt) ||\r\n\t\t\tmatch.Sequences.Any(s=\u003Es==null || s.Player==Guid.Empty || s.Sequence\u003C0) ||\r\n\t\t\tmatch.Sequences.Select(s=\u003Es.Player).Distinct().Count()!=match.Sequences.Length)\r\n\t\t\tthrow new ArgumentException(\u0022Invalid host checkpoint.\u0022);\r\n\t\tif (lobby.Phase==LobbyPhase.ChoosingContracts \u0026\u0026 match.Match.Phase!=MatchPhase.Lobby ||\r\n\t\t\tlobby.Phase==LobbyPhase.Playing \u0026\u0026 match.Match.Phase is MatchPhase.Lobby or MatchPhase.EndGame ||\r\n\t\t\tlobby.Phase==LobbyPhase.Finished \u0026\u0026 match.Match.Phase!=MatchPhase.EndGame || !Enum.IsDefined(match.Match.Phase))\r\n\t\t\tthrow new ArgumentException(\u0022Lobby and match phases disagree.\u0022);\r\n\t}\r\n}\r\n"},{"Ident":"degg.city_sim","Path":"Net/HostMatchVault.cs","FileName":"HostMatchVault.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":443612,"IsPrivate":false,"Code":"namespace CitySim.Net;\r\n\r\n/// \u003Csummary\u003E\r\n/// Host persistence on a dedicated NetworkMode.Never root. The engine includes local roots in its\r\n/// successor-only handoff snapshot, and excludes them from ordinary join/resync snapshots.\r\n/// Never attach this to a replicated object, or put its payload on a Sync property/RPC broadcast.\r\n/// \u003C/summary\u003E\r\npublic sealed class HostMatchVault : Component\r\n{\r\n\tstring payload;\r\n\tinternal Func\u003Cstring\u003E Capture { get; set; }\r\n\t[Property, Hide] public string Payload\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tif (GameObject.NetworkMode != NetworkMode.Never) throw new InvalidOperationException(\u0022Private host vault must stay on its dedicated local root.\u0022);\r\n\t\t\treturn Capture?.Invoke() ?? payload;\r\n\t\t}\r\n\t\tset =\u003E payload=value;\r\n\t}\r\n}\r\n"},{"Ident":"degg.city_sim","Path":"Tiles/CityGrid.cs","FileName":"CityGrid.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":443612,"IsPrivate":false,"Code":"namespace CitySim;\r\n\r\n/// \u003Csummary\u003E\r\n/// The playfield. Owns every tile, spawns the models for them and answers neighbour / path queries.\r\n/// \u003C/summary\u003E\r\npublic sealed class CityGrid : Component\r\n{\r\n\t/// \u003Csummary\u003EWorld size of one cell. The old art is 200 units wide, scaled by \u003Csee cref=\u0022ModelScale\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic const float CellSize = 50f;\r\n\tpublic const float ModelScale = 0.25f;\r\n\tpublic const float SurfaceHeight = 3.75f;\r\n\tconst float DropHeight = 25f;\r\n\tconst float DropDuration = 0.35f;\r\n\r\n\tstatic readonly Color[] ShopTints =\r\n\t{\r\n\t\tnew( 1f, 1f, 1f ), new( 1f, 0.9f, 0.75f ), new( 0.8f, 0.95f, 1f ), new( 1f, 0.85f, 0.9f ), new( 0.85f, 1f, 0.85f ),\r\n\t};\r\n\r\n\t// Up, Right, Down, Left - same ordering as the original game.\r\n\tpublic static readonly Cell[] Directions =\r\n\t{\r\n\t\tnew( 0, -1 ), new( 1, 0 ), new( 0, 1 ), new( -1, 0 ),\r\n\t};\r\n\r\n\tpublic int Width { get; private set; }\r\n\tpublic int Height { get; private set; }\r\n\tpublic Tile[,] Tiles { get; private set; }\r\n\r\n\tpublic int EmptyCount { get; private set; }\r\n\tpublic int TotalCount =\u003E Width * Height;\r\n\r\n\treadonly List\u003CTile\u003E dropping = new();\r\n\tbool generating;\r\n\tint placeCounter;\r\n\r\n\tpublic bool IsBuilt =\u003E Tiles != null;\r\n\tpublic int RoadRevision { get; private set; }\r\n\r\n\tpublic void Build( int width, int height, int seed = 0, bool generateStreets = true )\r\n\t{\r\n\t\tClear();\r\n\r\n\t\tWidth = width;\r\n\t\tHeight = height;\r\n\t\tTiles = new Tile[width, height];\r\n\t\tBuildBoard();\r\n\r\n\t\tvar groundModel = GroundModel();\r\n\r\n\t\tfor ( int x = 0; x \u003C width; x\u002B\u002B )\r\n\t\tfor ( int y = 0; y \u003C height; y\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar tile = new Tile { X = x, Y = y, Center = CellToWorld( x, y ) };\r\n\t\t\t// Quiet variation keeps the grid legible without a high-contrast checkerboard.\r\n\t\t\ttile.GroundBaseTint = CardVisualVariation.Ground(null,x,y);\r\n\r\n\t\t\ttile.Ground = new GameObject( true, $\u0022Ground {x},{y}\u0022 );\r\n\t\t\ttile.Ground.Parent = GameObject;\r\n\t\t\ttile.Ground.WorldPosition = tile.Center;\r\n\t\t\ttile.Ground.WorldScale = 1f;\r\n\r\n\t\t\tvar renderer = tile.Ground.Components.Create\u003CModelRenderer\u003E();\r\n\t\t\trenderer.Model = groundModel;\r\n\t\t\ttile.GroundRenderer = renderer;\r\n\t\t\trenderer.Tint = tile.GroundBaseTint;\r\n\r\n\t\t\tTiles[x, y] = tile;\r\n\t\t}\r\n\r\n\t\tEmptyCount = width * height;\r\n\t\tgenerating = true;\r\n\t\tplaceCounter = 0;\r\n\t\tif ( generateStreets )\r\n\t\t\tGenerateStreets( new Random( seed ) );\r\n\t\tgenerating = false;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EA layered cardboard backing and printed dashes frame the playable cells.\u003C/summary\u003E\r\n\tvoid BuildBoard()\r\n\t{\r\n\t\tvar width = Width * CellSize;\r\n\t\tvar height = Height * CellSize;\r\n\t\tconst float rim = 30f;\r\n\t\tvar layers = new Dictionary\u003CColor, VertexBuffer\u003E();\r\n\t\tvoid Box( float x, float y, float z, float sx, float sy, float sz, Color color )\r\n\t\t{\r\n\t\t\tif ( !layers.TryGetValue( color, out var vertices ) )\r\n\t\t\t{\r\n\t\t\t\tvertices = new VertexBuffer(); vertices.Init( true ); layers[color] = vertices;\r\n\t\t\t}\r\n\t\t\tvertices.AddCube( new Vector3( x, y, z ), new Vector3( sx, sy, sz ), Rotation.Identity, Color.White );\r\n\t\t}\r\n\r\n\t\tvar ink = new Color( 0.165f, 0.129f, 0.094f );\r\n\t\tvar core = new Color( 0.78f, 0.60f, 0.37f );\r\n\t\tvar paper = new Color( 0.95f, 0.90f, 0.77f );\r\n\t\t// Each layer is a single rectangle, so the board also fills gaps between tiles.\r\n\t\tBox( 0, 0, -9f, width \u002B rim * 2 \u002B 4, height \u002B rim * 2 \u002B 4, 2, ink );\r\n\t\tBox( 0, 0, -5f, width \u002B rim * 2, height \u002B rim * 2, 6, core );\r\n\t\tBox( 0, 0, -1.5f, width \u002B rim * 2, height \u002B rim * 2, 1, paper );\r\n\r\n\t\t// Center each run independently so the corners stay tidy at any board size.\r\n\t\tvoid Dashes( float length, float offset, bool horizontal )\r\n\t\t{\r\n\t\t\tvar count = Math.Max( 1, (int)(length / 24f) );\r\n\t\t\tvar spacing = length / count;\r\n\t\t\tfor ( var i = 0; i \u003C count; i\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tvar along = -length * 0.5f \u002B (i \u002B 0.5f) * spacing;\r\n\t\t\t\tif ( horizontal ) Box( along, offset, -0.85f, spacing * 0.55f, 2.5f, 0.3f, ink );\r\n\t\t\t\telse Box( offset, along, -0.85f, 2.5f, spacing * 0.55f, 0.3f, ink );\r\n\t\t\t}\r\n\t\t}\r\n\t\tDashes( width, height * 0.5f \u002B rim * 0.5f, true );\r\n\t\tDashes( width, -height * 0.5f - rim * 0.5f, true );\r\n\t\tDashes( height, width * 0.5f \u002B rim * 0.5f, false );\r\n\t\tDashes( height, -width * 0.5f - rim * 0.5f, false );\r\n\r\n\t\tvar board = new GameObject( true, \u0022Cardboard board\u0022 ) { Parent = GameObject };\r\n\t\tboard.WorldPosition = WorldPosition;\r\n\t\tforeach ( var layer in layers )\r\n\t\t{\r\n\t\t\tvar mesh = new Mesh( Material.Load( \u0022materials/board/board_flat.vmat\u0022 ) );\r\n\t\t\tmesh.CreateBuffers( layer.Value );\r\n\t\t\tvar part = new GameObject( true, \u0022Board layer\u0022 ) { Parent = board };\r\n\t\t\tpart.LocalPosition = Vector3.Zero;\r\n\t\t\tvar renderer = part.Components.Create\u003CModelRenderer\u003E();\r\n\t\t\trenderer.Model = new ModelBuilder().AddMesh( mesh ).Create();\r\n\t\t\trenderer.Tint = layer.Key;\r\n\t\t}\r\n\t}\r\n\r\n\tstatic Model groundTileModel;\r\n\tstatic Model GroundModel()\r\n\t{\r\n\t\tif ( groundTileModel != null ) return groundTileModel;\r\n\t\tvar vertices = new VertexBuffer(); vertices.Init( true );\r\n\t\tvertices.AddCube( Vector3.Up * (SurfaceHeight * 0.5f),\r\n\t\t\tnew Vector3( CellSize - 0.6f, CellSize - 0.6f, SurfaceHeight ), Rotation.Identity, Color.White );\r\n\t\tvar mesh = new Mesh( Material.Load( \u0022materials/board/board_flat.vmat\u0022 ) );\r\n\t\tmesh.CreateBuffers( vertices );\r\n\t\treturn groundTileModel = new ModelBuilder().AddMesh( mesh ).Create();\r\n\t}\r\n\r\n\tpublic void Clear()\r\n\t{\r\n\t\tRoadRevision\u002B\u002B;\r\n\t\tforeach ( var child in GameObject.Children.ToList() )\r\n\t\t\tchild.Destroy();\r\n\r\n\t\tTiles = null;\r\n\t\tdropping.Clear();\r\n\t\tEmptyCount = 0;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Lay a couple of random roads across the map so there is always somewhere to start.\r\n\t/// \u003C/summary\u003E\r\n\tvoid GenerateStreets( Random rng )\r\n\t{\r\n\t\tvar rows = rng.Next( 1, 5 );\r\n\t\tvar cols = rng.Next( 1, 5 );\r\n\r\n\t\tfor ( int i = 0; i \u003C rows; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar y = rng.Next( 0, Height );\r\n\t\t\tfor ( int x = 0; x \u003C Width; x\u002B\u002B )\r\n\t\t\t\tPlace( x, y, new Piece { Type = TileType.Road }, animate: false );\r\n\t\t}\r\n\r\n\t\tfor ( int i = 0; i \u003C cols; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar x = rng.Next( 0, Width );\r\n\t\t\tfor ( int y = 0; y \u003C Height; y\u002B\u002B )\r\n\t\t\t\tPlace( x, y, new Piece { Type = TileType.Road }, animate: false );\r\n\t\t}\r\n\t}\r\n\r\n\tpublic Vector3 CellToWorld( int x, int y )\r\n\t{\r\n\t\tvar origin = WorldPosition - new Vector3( (Width - 1) * 0.5f, (Height - 1) * 0.5f, 0 ) * CellSize;\r\n\t\treturn origin \u002B new Vector3( x * CellSize, y * CellSize, 0 );\r\n\t}\r\n\r\n\tpublic Vector3 Size =\u003E new( Width * CellSize, Height * CellSize, 0 );\r\n\r\n\tpublic Tile Get( int x, int y )\r\n\t{\r\n\t\tif ( Tiles == null || x \u003C 0 || y \u003C 0 || x \u003E= Width || y \u003E= Height )\r\n\t\t\treturn null;\r\n\r\n\t\treturn Tiles[x, y];\r\n\t}\r\n\r\n\tpublic Tile Neighbour( Tile tile, int direction )\r\n\t{\r\n\t\tvar d = Directions[direction];\r\n\t\treturn Get( tile.X \u002B d.X, tile.Y \u002B d.Y );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Find which tile a world position falls inside, or null if it is off the map.\r\n\t/// \u003C/summary\u003E\r\n\tpublic Tile WorldToTile( Vector3 world )\r\n\t{\r\n\t\tvar local = world - CellToWorld( 0, 0 ) \u002B new Vector3( CellSize, CellSize, 0 ) * 0.5f;\r\n\t\treturn Get( (local.x / CellSize).FloorToInt(), (local.y / CellSize).FloorToInt() );\r\n\t}\r\n\r\n\t// ---------------------------------------------------------------- placement\r\n\r\n\tpublic bool CanPlace( Tile tile, Piece piece )\r\n\t{\r\n\t\tif ( tile == null || piece.IsEmpty || tile.Type != TileType.Empty )\r\n\t\t\treturn false;\r\n\r\n\t\tif ( TileRules.NeedsRoad( piece.Type ) )\r\n\t\t\treturn RoadNeighbourDirection( tile ) \u003E= 0;\r\n\r\n\t\treturn true;\r\n\t}\r\n\r\n\tpublic bool Place( int x, int y, Piece piece, bool animate = true, bool legacyRules = true )\r\n\t{\r\n\t\tvar tile = Get( x, y );\r\n\t\tif ( legacyRules ? !CanPlace( tile, piece ) : tile == null || piece.IsEmpty || tile.Type != TileType.Empty )\r\n\t\t\treturn false;\r\n\r\n\t\ttile.Type = piece.Type;\r\n\t\tif (piece.Type == TileType.Road) RoadRevision\u002B\u002B;\r\n\t\ttile.Piece = piece;\r\n\t\ttile.ByPlayer = !generating;\r\n\t\ttile.Order = \u002B\u002BplaceCounter;\r\n\t\tEmptyCount--;\r\n\r\n\t\tSpawnBuilding( tile, animate );\r\n\r\n\t\t// Roads next to us need to re-shape themselves.\r\n\t\tfor ( int i = 0; i \u003C 4; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar n = Neighbour( tile, i );\r\n\t\t\tif ( n != null \u0026\u0026 n.IsRoad )\r\n\t\t\t\tRefreshRoad( n );\r\n\t\t}\r\n\r\n\t\treturn true;\r\n\t}\r\n\r\n\tinternal bool ClearBuilding(int x,int y)\r\n\t{\r\n\t\tvar tile=Get(x,y); if (tile == null || tile.Type==TileType.Empty) return false;\r\n\t\tif (tile.IsRoad) RoadRevision\u002B\u002B;\r\n\t\tdropping.Remove(tile);\r\n\t\tif (tile.Building.IsValid()) tile.Building.Destroy();\r\n\t\ttile.Building=null; tile.BuildingRenderer=null; tile.Type=TileType.Empty; tile.Piece=default;\r\n\t\ttile.CardId=null; tile.Owner=Guid.Empty; tile.Order=0; tile.ByPlayer=false; tile.BaseTint=Color.White; tile.DropTime=0;\r\n\t\ttile.GroundBaseTint=CardVisualVariation.Ground(null,x,y);\r\n\t\tif (tile.GroundRenderer.IsValid()) tile.GroundRenderer.Tint=tile.GroundBaseTint;\r\n\t\tif (tile.Ground.IsValid()) tile.Ground.Enabled=true;\r\n\t\tEmptyCount\u002B\u002B; return true;\r\n\t}\r\n\tvoid SpawnBuilding( Tile tile, bool animate )\r\n\t{\r\n\t\tvar piece = tile.Piece;\r\n\r\n\t\ttile.Building = new GameObject( true, $\u0022{piece.Type} {tile.X},{tile.Y}\u0022 );\r\n\t\ttile.Building.Parent = GameObject;\r\n\t\ttile.Building.WorldScale = ModelScale;\r\n\r\n\t\tvar renderer = tile.Building.Components.Create\u003CModelRenderer\u003E();\r\n\t\ttile.BuildingRenderer = renderer;\r\n\r\n\t\tswitch ( piece.Type )\r\n\t\t{\r\n\t\t\tcase TileType.Road:\r\n\t\t\t\tRefreshRoad( tile );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase TileType.House:\r\n\t\t\t\trenderer.Model = Model.Load( TileRules.House );\r\n\t\t\t\trenderer.SetBodyGroup( \u0022base\u0022, piece.BodyGroup );\r\n\t\t\t\tApplyMaterialGroup( renderer, piece.MaterialIndex );\r\n\t\t\t\tFaceRoad( tile );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase TileType.Business:\r\n\t\t\t\trenderer.Model = Model.Load( TileRules.Shop );\r\n\t\t\t\trenderer.SetBodyGroup( \u0022base\u0022, piece.BodyGroup );\r\n\t\t\t\ttile.BaseTint = ShopTints[piece.MaterialIndex % ShopTints.Length];\r\n\t\t\t\trenderer.Tint = tile.BaseTint;\r\n\t\t\t\tFaceRoad( tile );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase TileType.Park:\r\n\t\t\t\trenderer.Model = Model.Load( TileRules.Forest );\r\n\t\t\t\ttile.Building.WorldRotation = Rotation.Identity;\r\n\t\t\t\tbreak;\r\n\t\t}\r\n\r\n\t\t// Roads and parks replace the ground tile entirely.\r\n\t\tif ( piece.Type == TileType.Road || piece.Type == TileType.Park )\r\n\t\t\ttile.Ground.Enabled = false;\r\n\r\n\t\tif ( animate )\r\n\t\t{\r\n\t\t\ttile.DropTime = DropDuration;\r\n\t\t\ttile.Building.WorldPosition = tile.Center \u002B Vector3.Up * DropHeight;\r\n\t\t\tdropping.Add( tile );\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\ttile.Building.WorldPosition = tile.Center;\r\n\t\t}\r\n\t}\r\n\r\n\tpublic static void ApplyMaterialGroup( ModelRenderer renderer, int index )\r\n\t{\r\n\t\tvar model = renderer.Model;\r\n\t\tif ( model == null || model.MaterialGroupCount == 0 )\r\n\t\t\treturn;\r\n\r\n\t\trenderer.MaterialGroup = model.GetMaterialGroupName( index % model.MaterialGroupCount );\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tfor ( int i = dropping.Count - 1; i \u003E= 0; i-- )\r\n\t\t{\r\n\t\t\tvar tile = dropping[i];\r\n\t\t\tif ( tile.Building == null || !tile.Building.IsValid() )\r\n\t\t\t{\r\n\t\t\t\tdropping.RemoveAt( i );\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\ttile.DropTime -= Time.Delta;\r\n\t\t\tvar t = 1f - MathX.Clamp( tile.DropTime / DropDuration, 0f, 1f );\r\n\t\t\ttile.Building.WorldPosition = tile.Center \u002B Vector3.Up * (DropHeight * ((1f - t) * (1f - t)));\r\n\r\n\t\t\tif ( tile.DropTime \u003C= 0 )\r\n\t\t\t{\r\n\t\t\t\ttile.Building.WorldPosition = tile.Center;\r\n\t\t\t\tdropping.RemoveAt( i );\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\t// ---------------------------------------------------------------- neighbours\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Direction index (see \u003Csee cref=\u0022Directions\u0022/\u003E) of the first road next to this tile, or -1.\r\n\t/// \u003C/summary\u003E\r\n\tpublic int RoadNeighbourDirection( Tile tile )\r\n\t{\r\n\t\tfor ( int i = 0; i \u003C 4; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tif ( Neighbour( tile, i )?.IsRoad == true )\r\n\t\t\t\treturn i;\r\n\t\t}\r\n\r\n\t\treturn -1;\r\n\t}\r\n\r\n\tpublic bool IsNextToRoad( Tile tile ) =\u003E RoadNeighbourDirection( tile ) \u003E= 0;\r\n\r\n\tpublic static float YawForDirection( int direction ) =\u003E direction switch\r\n\t{\r\n\t\t0 =\u003E 270f,\r\n\t\t1 =\u003E 0f,\r\n\t\t2 =\u003E 90f,\r\n\t\t_ =\u003E 180f,\r\n\t};\r\n\r\n\tvoid FaceRoad( Tile tile )\r\n\t{\r\n\t\tvar dir = RoadNeighbourDirection( tile );\r\n\t\tif ( dir \u003E= 0 )\r\n\t\t\ttile.Building.WorldRotation = Rotation.FromYaw( YawForDirection( dir ) );\r\n\t}\r\n\r\n\t// ---------------------------------------------------------------- roads\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Works out which road model and rotation a tile at this position needs, based on its neighbours.\r\n\t/// Off-map neighbours count as connected so streets run off the edge of the map.\r\n\t/// \u003C/summary\u003E\r\n\tpublic (string model, float yaw) RoadShape( int x, int y )\r\n\t{\r\n\t\tbool Connected( int dx, int dy )\r\n\t\t{\r\n\t\t\tvar n = Get( x \u002B dx, y \u002B dy );\r\n\t\t\treturn n == null || n.IsRoad;\r\n\t\t}\r\n\r\n\t\tvar up = Connected( 0, -1 );\r\n\t\tvar right = Connected( 1, 0 );\r\n\t\tvar down = Connected( 0, 1 );\r\n\t\tvar left = Connected( -1, 0 );\r\n\r\n\t\tvar count = (up ? 1 : 0) \u002B (right ? 1 : 0) \u002B (down ? 1 : 0) \u002B (left ? 1 : 0);\r\n\t\tfloat yaw = 0;\r\n\r\n\t\tswitch ( count )\r\n\t\t{\r\n\t\t\tcase 3:\r\n\t\t\t\tif ( !up ) yaw = 180;\r\n\t\t\t\telse if ( !right ) yaw = 270;\r\n\t\t\t\telse if ( !down ) yaw = 0;\r\n\t\t\t\telse yaw = 90;\r\n\t\t\t\treturn (\u0022models/kenney/roads/road-intersection.vmdl\u0022, yaw - 90);\r\n\r\n\t\t\tcase 2:\r\n\t\t\t\tif ( left \u0026\u0026 right )\r\n\t\t\t\t\treturn (\u0022models/kenney/roads/road-straight.vmdl\u0022, 90);\r\n\t\t\t\tif ( up \u0026\u0026 down )\r\n\t\t\t\t\treturn (\u0022models/kenney/roads/road-straight.vmdl\u0022, 0);\r\n\r\n\t\t\t\tif ( up ) yaw = left ? 180 : 270;\r\n\t\t\t\tif ( down ) yaw = left ? 90 : 0;\r\n\t\t\t\treturn (\u0022models/kenney/roads/road-bend.vmdl\u0022, yaw \u002B 90);\r\n\r\n\t\t\tcase 1:\r\n\t\t\t\tif ( up ) yaw = 0;\r\n\t\t\t\telse if ( right ) yaw = 90;\r\n\t\t\t\telse if ( down ) yaw = 180;\r\n\t\t\t\telse yaw = 270;\r\n\t\t\t\treturn (\u0022models/kenney/roads/road-end.vmdl\u0022, yaw \u002B 180);\r\n\r\n\t\t\tdefault:\r\n\t\t\t\treturn (\u0022models/kenney/roads/road-crossroad.vmdl\u0022, 0);\r\n\t\t}\r\n\t}\r\n\r\n\tinternal void RefreshRoad( Tile tile )\r\n\t{\r\n\t\tif ( tile.BuildingRenderer == null )\r\n\t\t\treturn;\r\n\r\n\t\tvar (model, yaw) = RoadShape( tile.X, tile.Y );\r\n\t\tif (tile.CardId == \u0022avenue\u0022 \u0026\u0026 model.EndsWith(\u0022road-crossroad.vmdl\u0022)) model = \u0022models/kenney/roads/road-crossroad-line.vmdl\u0022;\r\n\t\ttile.BuildingRenderer.Enabled = false;\r\n\t\tCardVisuals.BuildRoad(tile.Building,model,tile.CardId == \u0022roundabout\u0022);\r\n\t\ttile.BaseTint = tile.CardId == \u0022avenue\u0022 ? new Color(0.8f,0.85f,1f) : Color.White;\r\n\t\tCardVisuals.Tint(tile.Building,tile.BaseTint);\r\n\t\ttile.Building.WorldRotation = Rotation.FromYaw( yaw );\r\n\t}\r\n\r\n\t// ---------------------------------------------------------------- pathfinding\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Breadth first search across roads from one tile to another. Returns the road tiles to drive over, or null.\r\n\t/// \u003C/summary\u003E\r\n\tpublic List\u003CTile\u003E FindRoadPath( Tile from, Tile to )\r\n\t{\r\n\t\tif ( from == null || to == null || !from.IsRoad || !to.IsRoad )\r\n\t\t\treturn null;\r\n\r\n\t\tvar previous = new Dictionary\u003CTile, Tile\u003E { [from] = null };\r\n\t\tvar queue = new Queue\u003CTile\u003E();\r\n\t\tqueue.Enqueue( from );\r\n\r\n\t\twhile ( queue.Count \u003E 0 )\r\n\t\t{\r\n\t\t\tvar current = queue.Dequeue();\r\n\t\t\tif ( current == to )\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tfor ( int i = 0; i \u003C 4; i\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tvar n = Neighbour( current, i );\r\n\t\t\t\tif ( n == null || !n.IsRoad || previous.ContainsKey( n ) )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tprevious[n] = current;\r\n\t\t\t\tqueue.Enqueue( n );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif ( !previous.ContainsKey( to ) )\r\n\t\t\treturn null;\r\n\r\n\t\tvar path = new List\u003CTile\u003E();\r\n\t\tfor ( var t = to; t != null; t = previous[t] )\r\n\t\t\tpath.Add( t );\r\n\r\n\t\tpath.Reverse();\r\n\t\treturn path;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Road tiles touching the given tile.\r\n\t/// \u003C/summary\u003E\r\n\tpublic IEnumerable\u003CTile\u003E AdjacentRoads( Tile tile )\r\n\t{\r\n\t\tfor ( int i = 0; i \u003C 4; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar n = Neighbour( tile, i );\r\n\t\t\tif ( n != null \u0026\u0026 n.IsRoad )\r\n\t\t\t\tyield return n;\r\n\t\t}\r\n\t}\r\n\r\n\tpublic IEnumerable\u003CTile\u003E All()\r\n\t{\r\n\t\tif ( Tiles == null )\r\n\t\t\tyield break;\r\n\r\n\t\tfor ( int x = 0; x \u003C Width; x\u002B\u002B )\r\n\t\tfor ( int y = 0; y \u003C Height; y\u002B\u002B )\r\n\t\t\tyield return Tiles[x, y];\r\n\t}\r\n\r\n\tpublic IEnumerable\u003CTile\u003E EdgeRoads()\r\n\t{\r\n\t\treturn All().Where( t =\u003E t.IsRoad \u0026\u0026 (t.X == 0 || t.Y == 0 || t.X == Width - 1 || t.Y == Height - 1) );\r\n\t}\r\n}\r\n"},{"Ident":"degg.city_sim","Path":"Ui/CardInteractions.razor","FileName":"CardInteractions.razor","PackageType":"game","CodeKind":"Game","AssetVersionId":443612,"IsPrivate":false,"Code":"@namespace CitySim\r\n@using CitySim.Cards\r\n@using CitySim.Match\r\n@using Sandbox.UI\r\n@inherits Panel\r\n\r\n\u003Croot class=\u0022card-interactions\u0022\u003E\r\n    @if (Targets.Count \u003E 0)\r\n    {\r\n        \u003CInteractionChip City=@City Label=@(GameText.Get(\u0022ui.card_interactions.interacts_with\u0022)) Targets=@Targets Heading=@true /\u003E\r\n        \u003Cdiv class=\u0022interaction-list\u0022\u003E@foreach (var target in Targets) { \u003CInteractionChip City=@City Label=@(GameText.Plain(target.Label)) Targets=@(new[] { target }) /\u003E }\u003C/div\u003E\r\n    }\r\n\u003C/root\u003E\r\n@code {\r\n    [Parameter] public CityGame City { get; set; }\r\n    [Parameter] public CardDef Card { get; set; }\r\n    IReadOnlyList\u003CInteractionTarget\u003E Targets =\u003E CardPresentation.Targets(Card);\r\n    protected override int BuildHash() =\u003E HashCode.Combine(GameText.LanguageCode, Card?.Id.GetHashCode() ?? 0);\r\n}\r\n"},{"Ident":"degg.city_sim","Path":"Ui/DesignSystem/PlotterButton.razor","FileName":"PlotterButton.razor","PackageType":"game","CodeKind":"Game","AssetVersionId":443612,"IsPrivate":false,"Code":"@namespace CitySim.DesignSystem\r\n@using Sandbox.UI\r\n@using CitySim.Punchboard\r\n@inherits Panel\r\n\r\n\u003Croot class=\u0022plotter-button @EffectiveVariant @Size.Css() @CssClass @(Toggled ? \u0022toggled\u0022 : \u0022\u0022) @(Disabled ? \u0022disabled\u0022 : \u0022\u0022) @(Focused ? \u0022focused\u0022 : \u0022\u0022)\u0022\u003E\r\n    \u003Cdiv class=\u0022plotter-button-content\u0022\u003E\r\n        @if ( !string.IsNullOrEmpty( Icon ) ) { \u003Cspan\u003E@Icon\u003C/span\u003E }\r\n        @if ( !string.IsNullOrEmpty( DisplayLabel ) ) { \u003Cspan\u003E@(GameText.Plain(DisplayLabel))\u003C/span\u003E }\r\n        @ChildContent\r\n    \u003C/div\u003E\r\n\u003C/root\u003E\r\n\r\n@code\r\n{\r\n    [Parameter] public new RenderFragment ChildContent { get; set; }\r\n    [Parameter] public string Label { get; set; }\r\n    [Parameter] public string Text { get; set; }\r\n    [Parameter] public string Icon { get; set; }\r\n    [Parameter] public string CssClass { get; set; }\r\n    [Parameter] public PbButtonKind? Kind { get; set; }\r\n    [Parameter] public PbSize Size { get; set; } = PbSize.Normal;\r\n    [Parameter] public bool Toggled { get; set; }\r\n    string DisplayLabel =\u003E Label ?? Text;\r\n    string EffectiveVariant =\u003E Kind switch { PbButtonKind.Secondary =\u003E \u0022secondary\u0022, PbButtonKind.Tertiary =\u003E \u0022tertiary\u0022, PbButtonKind.Ghost =\u003E \u0022quiet\u0022, PbButtonKind.Danger =\u003E \u0022danger\u0022, PbButtonKind.Primary =\u003E \u0022primary\u0022, _ =\u003E Variant };\r\n    /// \u003Csummary\u003Eprimary, secondary, or quiet.\u003C/summary\u003E\r\n    [Parameter] public string Variant { get; set; } = \u0022primary\u0022;\r\n    [Parameter] public bool Disabled { get; set; }\r\n    /// \u003Csummary\u003EExternal focus marker for an owning navigation system; native focus is also styled.\u003C/summary\u003E\r\n    [Parameter] public bool Focused { get; set; }\r\n\r\n    [Parameter] public string Help { get; set; }\r\n    [Parameter] public Action Clicked { get; set; }\r\n\r\n    protected override void OnParametersSet()\r\n    {\r\n        base.OnParametersSet();\r\n        AcceptsFocus = !Disabled;\r\n        Tooltip = Help;\r\n        TooltipClass = \u0022city-tooltip\u0022;\r\n    }\r\n    protected override void OnClick( MousePanelEvent e ) { base.OnClick( e ); Activate(); }\r\n    public override void OnButtonEvent( ButtonEvent e )\r\n    {\r\n        base.OnButtonEvent( e );\r\n        if ( HasFocus \u0026\u0026 e.Pressed \u0026\u0026 (e.Button == \u0022enter\u0022 || e.Button == \u0022space\u0022) ) Activate();\r\n    }\r\n    float lastActivation = -100;\r\n    public void Activate()\r\n    {\r\n        if ( Disabled || lastActivation == Time.Now ) return;\r\n        lastActivation = Time.Now;\r\n        TabletopAudio.Click();\r\n        Clicked?.Invoke();\r\n    }\r\n    protected override int BuildHash() =\u003E HashCode.Combine(GameText.LanguageCode, HashCode.Combine( Label, Text, Icon, CssClass, Kind, Size, Toggled, HashCode.Combine(Variant, Disabled, Focused, Help) ));\r\n}\r\n\r\n"},{"Ident":"degg.city_sim","Path":"Ui/PunchboardMapping.cs","FileName":"PunchboardMapping.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":443612,"IsPrivate":false,"Code":"using CitySim.Cards;\r\nusing CitySim.Punchboard;\r\n\r\nnamespace CitySim;\r\n\r\npublic static class PunchboardMapping\r\n{\r\n    public static PbRarity Rarity(Rarity rarity) =\u003E (PbRarity)(int)rarity;\r\n    public static PbTag Tag(Tag tag) =\u003E tag switch { Cards.Tag.Trade =\u003E PbTag.Trade, Cards.Tag.Nature =\u003E PbTag.Nature, Cards.Tag.Industry =\u003E PbTag.Industry, Cards.Tag.Culture =\u003E PbTag.Culture, Cards.Tag.Civic =\u003E PbTag.Civic, _ =\u003E PbTag.Home };\r\n    public static Color Accent(Tag tags) =\u003E TagChip.Values(tags).FirstOrDefault() switch {\r\n        Cards.Tag.Home =\u003E new Color(0.85f, 0.28f, 0.23f), Cards.Tag.Trade =\u003E new Color(0.94f, 0.72f, 0.23f),\r\n        Cards.Tag.Nature =\u003E new Color(0.29f, 0.65f, 0.39f), Cards.Tag.Industry =\u003E new Color(0.54f, 0.52f, 0.47f),\r\n        Cards.Tag.Culture =\u003E new Color(0.64f, 0.41f, 0.78f), Cards.Tag.Civic =\u003E new Color(0.23f, 0.55f, 0.82f),\r\n        _ =\u003E new Color(0.94f, 0.72f, 0.23f) };\r\n}\r\n"},{"Ident":"degg.city_sim","Path":"Game/Sfx.cs","FileName":"Sfx.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":443612,"IsPrivate":false,"Code":"namespace CitySim;\r\n\r\n/// \u003Csummary\u003E\r\n/// Sound helper that never throws, so a missing sound event can\u0027t break gameplay.\r\n/// \u003C/summary\u003E\r\npublic static class Sfx\r\n{\r\n\tpublic static void Play( string name, Vector3 position, float pitch = 1f, float volume = 1f )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar handle = Sound.Play( name, position );\r\n\t\t\tif (handle != null) { handle.Pitch = pitch; handle.Volume *= PlayerPreferences.EffectsVolume * Math.Clamp(volume,0f,1f); }\r\n\t\t}\r\n\t\tcatch ( Exception )\r\n\t\t{\r\n\t\t}\r\n\t}\r\n}\r\n"},{"Ident":"degg.city_sim","Path":"Match/EconomyGrowthChecks.cs","FileName":"EconomyGrowthChecks.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":443612,"IsPrivate":false,"Code":"using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing CitySim.Cards;\r\n\r\nnamespace CitySim.Match;\r\n\r\npublic static class EconomyGrowthChecks\r\n{\r\n    static readonly Guid A = new(\u002211111111-1111-1111-1111-111111111111\u0022), B = new(\u002222222222-2222-2222-2222-222222222222\u0022);\r\n    public static IReadOnlyList\u003Cstring\u003E Run()\r\n    {\r\n        var passed = new List\u003Cstring\u003E();\r\n        void Check(bool ok,string name) { if (!ok) throw new InvalidOperationException(name); passed.Add(\u0022PASS \u0022\u002Bname); }\r\n        MatchState Board()\r\n        {\r\n            var s=new MatchState(13,13,1); s.AddPlayer(A,\u0022A\u0022); s.AddPlayer(B,\u0022B\u0022);\r\n            s.SetPhase(MatchPhase.Build,1,40); return s;\r\n        }\r\n        // Hand-built payout fixtures intentionally isolate effects from placement adjacency and road requirements.\r\n        BoardCell Add(MatchState s,string id,int x,int y,Guid? owner=null,int age=0)\r\n        {\r\n            var cell=new BoardCell(x,y,id,owner ?? A,s.Cells().Count()\u002B1,1,age); s.UpdateCell(cell); return cell;\r\n        }\r\n        int Income(MatchState s,string id) =\u003E Payout.Calculate(s).Entries.Where(e=\u003Ee.Source?.CardId==id \u0026\u0026 e.Reason==CardLibrary.Get(id).Name).Sum(e=\u003Ee.Coins);\r\n        var growth=Board(); Add(growth,\u0022vineyard\u0022,1,1); Add(growth,\u0022startup\u0022,3,1);\r\n        var vineyard=new List\u003Cint\u003E(); var startup=new List\u003Cint\u003E(); var grown=0; growth.Events.BuildingGrown\u002B=_=\u003Egrown\u002B\u002B;\r\n        for(var round=1;round\u003C=5;round\u002B\u002B)\r\n        {\r\n            growth.SetPhase(MatchPhase.Payout,round,round*40); vineyard.Add(Income(growth,\u0022vineyard\u0022)); startup.Add(Income(growth,\u0022startup\u0022));\r\n            var payout=Payout.Calculate(growth); var age=growth.Get(1,1).Age;\r\n            Check(Payout.Calculate(growth).CoinsFor(A)==payout.CoinsFor(A) \u0026\u0026 growth.Get(1,1).Age==age,$\u0022growth preview is pure in round {round}\u0022);\r\n            Check(Payout.Apply(growth,payout) \u0026\u0026 !Payout.Apply(growth,payout),$\u0022growth payout settles once in round {round}\u0022);\r\n            if(round==2) growth=MatchState.Restore(growth.Capture() with { PlacementOrder=growth.Cells().Max(c=\u003Ec.Order) });\r\n        }\r\n        Check(vineyard.SequenceEqual(new[]{0,0,3,3,3}),\u0022Vineyard waits for two completed payouts, including checkpoint restore\u0022);\r\n        Check(startup.SequenceEqual(new[]{1,2,3,3,3}),\u0022Startup ramps on its own payout age and caps at three\u0022);\r\n        Check(grown==0 \u0026\u0026 !Growth.IsMature(growth.Get(1,1)),\u0022income growth never invokes Forest growth or park awards\u0022);\r\n        var late=Board(); late.SetPhase(MatchPhase.Build,6,300); Add(late,\u0022startup\u0022,1,1); Add(late,\u0022vineyard\u0022,2,1);\r\n        Check(Income(late,\u0022startup\u0022)==1 \u0026\u0026 Income(late,\u0022vineyard\u0022)==0,\u0022late growth cards start at their first payout rather than match round\u0022);\r\n\r\n        var avenue=Board(); Add(avenue,\u0022avenue\u0022,5,5); Add(avenue,\u0022street\u0022,5,4); Add(avenue,\u0022house\u0022,4,5,B);\r\n        Check(Income(avenue,\u0022avenue\u0022)==0,\u0022Avenue ignores adjacent roads at the two-building threshold\u0022);\r\n        Add(avenue,\u0022park\u0022,6,5,B);\r\n        Check(Income(avenue,\u0022avenue\u0022)==1,\u0022Avenue counts buildings of either owner and pays its owner once\u0022);\r\n        Add(avenue,\u0022shop\u0022,5,6);\r\n        Check(Income(avenue,\u0022avenue\u0022)==1,\u0022Avenue income stays one above its threshold\u0022);\r\n        Check(RoadScoring.Longest(avenue,A)==3,\u0022Avenue counts double in a chain with one Street\u0022);\r\n\r\n        var market=Board(); Add(market,\u0022market\u0022,5,5); Add(market,\u0022shop\u0022,3,3); Add(market,\u0022shop\u0022,7,7,B); Add(market,\u0022shop\u0022,2,5); Add(market,\u0022cafe\u0022,4,5);\r\n        Check(Income(market,\u0022market\u0022)==1,\u0022Market includes diagonal range two but excludes rivals, distant Shops and Cafes\u0022);\r\n        foreach(var point in new[]{(4,4),(5,4),(6,4),(6,5)}) Add(market,\u0022shop\u0022,point.Item1,point.Item2);\r\n        Check(Income(market,\u0022market\u0022)==4,\u0022Market income caps at four qualifying owned Shops\u0022);\r\n\r\n        var lake=Board(); Add(lake,\u0022lake\u0022,5,5); Add(lake,\u0022house\u0022,5,4,B); Add(lake,\u0022apartment\u0022,4,5); Add(lake,\u0022cottage\u0022,6,5); Add(lake,\u0022cafe\u0022,5,6); Add(lake,\u0022house\u0022,6,6);\r\n        var lakeEntries=Payout.Calculate(lake).Entries.Where(e=\u003Ee.Reason==\u0022#rules.payout.beside_lake\u0022).ToArray();\r\n        Check(lakeEntries.Length==3 \u0026\u0026 lakeEntries.Count(e=\u003Ee.Player==B)==1 \u0026\u0026 lakeEntries.All(e=\u003Ee.Coins==1),\u0022Lake pays each orthogonally adjacent residential owner once, including Apartment and Cottage\u0022);\r\n        Check(BoardRules.IsPark(lake.Get(5,5)) \u0026\u0026 ParkScoring.Regions(lake).Single().Count==1,\u0022Lake contributes one physical park tile\u0022);\r\n        Check(FinalScoring.CardEffects(lake).Single(e=\u003Ee.Reason==\u0022#cards.cafe.name\u0022).Vp==2,\u0022Lake supplies Cafe\u0027s park end bonus\u0022);\r\n        Check(Payout.Calculate(lake).Entries.Any(e=\u003Ee.Source?.CardId==\u0022cottage\u0022 \u0026\u0026 e.Reason==\u0022#rules.payout.beside_park\u0022),\u0022Lake supplies Cottage\u0027s ordinary park income as well as the Lake bonus\u0022);\r\n        Add(lake,\u0022lake\u0022,10,10);\r\n        Check(PlacementScoring.Preview(lake,\u0022cottage\u0022,10,11,A)==1,\u0022Lake counts as a park neighbour for Cottage placement\u0022);\r\n\r\n        var orchard=Board(); Add(orchard,\u0022orchard\u0022,5,5); Add(orchard,\u0022farm\u0022,5,6,B); Add(orchard,\u0022shop\u0022,7,5,B); Add(orchard,\u0022park\u0022,4,5);\r\n        Check(Income(orchard,\u0022orchard\u0022)==1 \u0026\u0026 Income(orchard,\u0022farm\u0022)==1,\u0022adjacent Orchard and Farm independently pay their owners\u0022);\r\n        Check(ParkScoring.Region(orchard,orchard.Get(5,5)).Sum(ParkScoring.SizeOf)==2,\u0022Orchard also joins the adjacent park at size one\u0022);\r\n        return passed.AsReadOnly();\r\n    }\r\n}\r\n"},{"Ident":"degg.city_sim","Path":"Match/FinalScoring.cs","FileName":"FinalScoring.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":443612,"IsPrivate":false,"Code":"using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing CitySim.Cards;\r\n\r\nnamespace CitySim.Match;\r\n\r\npublic sealed record ScoreLine(Guid Player,string Reason,int Vp,string Detail = \u0022\u0022,BoardCell Source = null,bool SecretContract = false);\r\npublic sealed record PlayerScore(Guid Player,string Name,int EarnedVp,int BonusVp,int TotalVp,int Coins,IReadOnlyList\u003CScoreLine\u003E Lines);\r\npublic sealed record EndScore(IReadOnlyList\u003CPlayerScore\u003E Players,IReadOnlyList\u003CGuid\u003E Winners);\r\n\r\npublic static class FinalScoring\r\n{\r\n\tpublic static EndScore Calculate(MatchState state)\r\n\t{\r\n\t\tif (state.FinalScore != null) return state.FinalScore;\r\n\t\tvar cells = state.Cells().OrderBy(c =\u003E c.Order).ToArray(); var lines = new List\u003CScoreLine\u003E();\r\n\t\tforeach (var player in state.Players.Values)\r\n\t\t{\r\n\t\t\tvar goalVp = state.Goals.History.SelectMany(c =\u003E c.Awards).Where(a =\u003E a.Player == player.Id).Sum(a =\u003E a.Vp);\r\n\t\t\tlines.Add(new(player.Id,\u0022#rules.final_scoring.earned_during_match\u0022,player.Vp-goalVp,\u0022#rules.final_scoring.placement_parks_and_other_match_awards\u0022));\r\n\t\t}\r\n\t\tforeach (var claim in state.Goals.History) foreach (var award in claim.Awards) lines.Add(new(award.Player,\u0022#rules.final_scoring.public_goal\u0022\u002BGoalLibrary.Get(claim.GoalId).Name,award.Vp,GameText.Message(\u0022rules.final_scoring.round\u0022, claim.Round, (claim.Awards.Count \u003E 1 ? \u0022#rules.final_scoring.shared_claim\u0022 : \u0022#rules.final_scoring.sole_claim\u0022))));\r\n\t\tlines.AddRange(CardEffects(state));\r\n\t\tvoid Competition(string name,int pool,Func\u003CGuid,int\u003E measure)\r\n\t\t{\r\n\t\t\tvar scores = state.Players.Keys.ToDictionary(id =\u003E id,measure);\r\n\t\t\tvar maximum = scores.Count \u003E 0 ? scores.Values.Max() : 0;\r\n\t\t\tvar tied = scores.Count(p =\u003E p.Value == maximum);\r\n\t\t\tforeach (var p in scores)\r\n\t\t\t\tlines.Add(new(p.Key,name,maximum \u003E 0 \u0026\u0026 p.Value == maximum ? pool/tied : 0,GameText.Message(\u0022rules.final_scoring.your_measure_leader\u0022, p.Value, maximum) \u002B (maximum \u003C= 0 ? \u0022#rules.final_scoring.no_qualifying_entry\u0022 : tied \u003E 1 ? GameText.Message(\u0022rules.final_scoring.tied_vp_split_rounded_down\u0022, tied, pool) : GameText.Message(\u0022rules.final_scoring.winner_receives_vp\u0022, pool))));\r\n\t\t}\r\n\t\tvar parks = ParkScoring.Regions(state);\r\n\t\tCompetition(\u0022#rules.final_scoring.longest_road\u0022,state.Settings.RoadBonusVp,id =\u003E RoadScoring.Longest(state,id));\r\n\t\tCompetition(\u0022#rules.final_scoring.biggest_park_owner\u0022,state.Settings.ParkBonusVp,id =\u003E parks.Select(region =\u003E region.Count(c =\u003E c.Owner == id)).DefaultIfEmpty(0).Max());\r\n\t\tCompetition(\u0022#rules.final_scoring.full_house\u0022,state.Settings.HousesBonusVp,id =\u003E cells.Count(c =\u003E c.Owner == id \u0026\u0026 BoardRules.IsHome(c)));\r\n\t\tforeach (var reveal in state.ContractReveals) lines.Add(new(reveal.Player,\u0022#rules.final_scoring.secret_contract\u0022\u002BContracts.Get(reveal.ContractId).Name,reveal.Vp,reveal.Detail\u002B(reveal.Complete ? \u0022#rules.final_scoring.fulfilled\u0022 : \u0022#rules.final_scoring.not_fulfilled\u0022),SecretContract:true));\r\n\t\tforeach (var p in state.Players.Values) lines.Add(new(p.Id,\u0022#rules.final_scoring.spare_coins\u0022,p.Coins/state.Settings.CoinsPerEndVp,GameText.Message(\u0022rules.final_scoring.coin_conversion\u0022, p.Coins, state.Settings.CoinsPerEndVp)));\r\n\t\tvar players = state.Players.Values.Select(p =\u003E\r\n\t\t{\r\n\t\t\tvar ownLines = lines.Where(l =\u003E l.Player == p.Id).ToList().AsReadOnly(); var total = ownLines.Sum(l =\u003E l.Vp);\r\n\t\t\treturn new PlayerScore(p.Id,p.Name,p.Vp,total-p.Vp,total,p.Coins,ownLines);\r\n\t\t}).OrderByDescending(p =\u003E p.TotalVp).ThenByDescending(p =\u003E p.Coins).ThenBy(p =\u003E p.Player).ToList();\r\n\t\tvar first = players.FirstOrDefault();\r\n\t\treturn new(players.AsReadOnly(),players.Where(p =\u003E p.TotalVp == first.TotalVp \u0026\u0026 p.Coins == first.Coins).Select(p =\u003E p.Player).ToList().AsReadOnly());\r\n\t}\r\n\t/// \u003Csummary\u003EPure per-card end-game awards, in placement order, without competition or secret scoring.\u003C/summary\u003E\r\n\tpublic static IReadOnlyList\u003CScoreLine\u003E CardEffects(MatchState state)\r\n\t{\r\n\t\tvar cells = state.Cells().OrderBy(c =\u003E c.Order).ToArray();\r\n\t\tvar byOwner = cells.ToLookup(c =\u003E c.Owner);\r\n\t\tvar lines = new List\u003CScoreLine\u003E();\r\n\t\tvar airportLoss = new Dictionary\u003CGuid,int\u003E();\r\n\t\tforeach (var cell in cells)\r\n\t\tforeach (var effect in CardLibrary.Get(cell.CardId).Effects.Where(e =\u003E e.Hook == EffectHook.EndGame))\r\n\t\t{\r\n\t\t\tvar card = CardLibrary.Get(cell.CardId); var own = byOwner[cell.Owner];\r\n\t\t\tif (effect.Rule == \u0022vp\u0022) lines.Add(new(cell.Owner,card.Name,effect.Amount,\u0022#rules.final_scoring.end_game_card\u0022,cell));\r\n\t\t\tif (effect.Rule == \u0022stadium-house-vp\u0022) lines.Add(new(cell.Owner,card.Name,state.Neighbours(cell.X,cell.Y).Count(BoardRules.IsHome)*effect.Amount,\u0022#rules.final_scoring.adjacent_home_buildings_any_owner\u0022,cell));\r\n\t\t\tif (effect.Rule == \u0022nearby-building-vp\u0022) lines.Add(new(cell.Owner,card.Name,Math.Min(effect.Cap,cells.Count(c =\u003E c.Order != cell.Order \u0026\u0026 CardLibrary.Get(c.CardId).Type is not (CardType.Road or CardType.Event) \u0026\u0026 BoardRules.Distance(cell,c) \u003C= effect.Range)*effect.Amount),GameText.Message(\u0022rules.final_scoring.buildings_within_any_owner_cap\u0022, effect.Range, effect.Cap),cell));\r\n\t\t\tif (effect.Rule == \u0022airport-noise\u0022)\r\n\t\t\t\tforeach (var victim in cells.Where(c =\u003E BoardRules.IsHome(c) \u0026\u0026 BoardRules.Distance(cell,c) \u003C= effect.Range))\r\n\t\t\t\t{\r\n\t\t\t\t\tvar loss = Math.Min(effect.Amount,Math.Max(0,effect.Cap-airportLoss.GetValueOrDefault(victim.Owner)));\r\n\t\t\t\t\tif (loss == 0) continue;\r\n\t\t\t\t\tairportLoss[victim.Owner] = airportLoss.GetValueOrDefault(victim.Owner)\u002Bloss;\r\n\t\t\t\t\tlines.Add(new(victim.Owner,\u0022#rules.final_scoring.airport_noise\u0022,-loss,GameText.Message(\u0022rules.final_scoring.at_combined_airport_penalty_cap\u0022, CardLibrary.Get(victim.CardId).Name, victim.X, victim.Y, effect.Cap),cell));\r\n\t\t\t\t}\r\n\t\t\tif (effect.Rule == \u0022tag-majorities-vp\u0022)\r\n\t\t\t{\r\n\t\t\t\tvar tags = new[] { Tag.Home,Tag.Trade,Tag.Nature,Tag.Industry,Tag.Culture,Tag.Civic };\r\n\t\t\t\tvar qualified = tags.Where(tag =\u003E own.Count(c =\u003E CardLibrary.Get(c.CardId).Type is not (CardType.Road or CardType.Event) \u0026\u0026 CardLibrary.Get(c.CardId).Tags.HasFlag(tag)) \u003E= effect.Divisor).ToArray();\r\n\t\t\t\tlines.Add(new(cell.Owner,card.Name,Math.Min(effect.Cap,qualified.Length*effect.Amount),GameText.Message(\u0022rules.final_scoring.tags_with_at_least_buildings_cap\u0022, qualified.Length, effect.Divisor, string.Join(\u0022, \u0022,qualified.Select(tag =\u003E \u0022#enums.tag.\u0022 \u002B tag.ToString().ToLowerInvariant())), effect.Cap),cell));\r\n\t\t\t}\r\n\t\t\tif (effect.Rule == \u0022other-culture-vp\u0022)\r\n\t\t\t{\r\n\t\t\t\tvar count = own.Count(c =\u003E c.Order != cell.Order \u0026\u0026 CardLibrary.Get(c.CardId).Tags.HasFlag(Tag.Culture));\r\n\t\t\t\tlines.Add(new(cell.Owner,card.Name,Math.Min(effect.Cap,count*effect.Amount),GameText.Message(\u0022rules.final_scoring.other_culture_buildings_cap\u0022, count, effect.Cap),cell));\r\n\t\t\t}\r\n\t\t\tif (effect.Rule == \u0022home-thirds-vp\u0022)\r\n\t\t\t{\r\n\t\t\t\tvar count = own.Count(c =\u003E CardLibrary.Get(c.CardId).Tags.HasFlag(Tag.Home));\r\n\t\t\t\tlines.Add(new(cell.Owner,card.Name,Math.Min(effect.Cap,count/effect.Divisor*effect.Amount),GameText.Message(\u0022rules.final_scoring.home_buildings_1_vp_per_cap\u0022, count, effect.Divisor, effect.Cap),cell));\r\n\t\t\t}\r\n\t\t\tif (effect.Rule == \u0022eco-council-vp\u0022)\r\n\t\t\t{\r\n\t\t\t\tvar nature = own.Count(c =\u003E CardLibrary.Get(c.CardId).Tags.HasFlag(Tag.Nature));\r\n\t\t\t\tvar industry = own.Count(c =\u003E CardLibrary.Get(c.CardId).Tags.HasFlag(Tag.Industry));\r\n\t\t\t\tvar vp = Math.Max(0,Math.Min(effect.Cap,nature/effect.Divisor*effect.Amount)-effect.Range*industry);\r\n\t\t\t\tlines.Add(new(cell.Owner,card.Name,vp,GameText.Message(\u0022rules.final_scoring.nature_buildings_industry_buildings_cap_minus_per_industry\u0022, nature, industry, effect.Cap, effect.Range),cell));\r\n\t\t\t}\r\n\t\t\tif (effect.Rule == \u0022beside-park-vp\u0022) lines.Add(new(cell.Owner,card.Name,state.Neighbours(cell.X,cell.Y).Any(c =\u003E BoardRules.IsPark(c) || c.CardId == \u0022lake\u0022) ? effect.Amount : 0,\u0022#rules.final_scoring.end_bonus_if_adjacent_to_a_park\u0022,cell));\r\n\t\t\tif (effect.Rule == \u0022pollution\u0022)\r\n\t\t\t\tforeach (var victim in state.Neighbours(cell.X,cell.Y).Where(c =\u003E BoardRules.IsHome(c) || c.CardId == \u0022cafe\u0022))\r\n\t\t\t\t\tlines.Add(new(victim.Owner,\u0022#rules.final_scoring.factory_pollution\u0022,-effect.Amount,GameText.Message(\u0022rules.final_scoring.at_beside_factory_at\u0022, CardLibrary.Get(victim.CardId).Name, victim.X, victim.Y, cell.X, cell.Y),cell));\r\n\t\t}\r\n\t\treturn lines.AsReadOnly();\r\n\t}\r\n\r\n\tinternal static EndScore Apply(MatchState state)\r\n\t{\r\n\t\tif (state.FinalScore != null) return state.FinalScore;\r\n\t\tvar result = Calculate(state); state.FinalScore = result;\r\n\t\tforeach (var player in result.Players) state.ChangeVp(player.Player,player.BonusVp);\r\n\t\tstate.Events.Scored(result); return result;\r\n\t}\r\n}\r\n"},{"Ident":"degg.city_sim","Path":"Match/Replication.cs","FileName":"Replication.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":443612,"IsPrivate":false,"Code":"using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\n\r\nnamespace CitySim.Match;\r\n\r\npublic sealed record PublicPlayer(Guid Id,string Name,int Coins,int Vp,int HandCount,bool BuildReady,bool ContractReady);\r\npublic sealed record PublicAuction(int Id,int Round,int Number,int Cap,bool Resolving,AuctionLot[] Lots,AuctionParticipant[] Players);\r\n/// \u003Csummary\u003ENo seed, shuffle state, secret submissions, offers or hands belong in this broadcast.\u003C/summary\u003E\r\npublic sealed record PublicMatchSnapshot(int Width,int Height,MatchSettings Settings,int Round,MatchPhase Phase,double PhaseEndsAt,\r\n\tPublicPlayer[] Players,BoardCell[] Cells,TileClaim[] Claims,PublicGoal[] Goals,GoalClaim[] GoalHistory,PublicAuction Auction,\r\n\tAuctionResult LastAuction,PayoutResult LastPayout,ContractReveal[] ContractReveals,EndScore FinalScore,RoundActivity[] Activity=null);\r\npublic sealed record OwnerMatchSnapshot(Guid Owner,long HandVersion,string[] Hand,ContractView Contract,AuctionView Auction,bool BuilderCommon=false);\r\npublic sealed record RecipientSnapshot(int Version,Guid MatchId,long Revision,double Clock,PublicMatchSnapshot Public,OwnerMatchSnapshot Own);\r\n\r\n/// \u003Csummary\u003EOnly recipient-filtered state becomes a client\u0027s model. Rival hands stay empty.\u003C/summary\u003E\r\npublic sealed class MatchReplica\r\n{\r\n\treadonly HashSet\u003CGuid\u003E retired = new();\r\n\tpublic MatchState State { get; private set; }\r\n\tpublic RecipientSnapshot Latest { get; private set; }\r\n\tpublic bool Apply(Guid viewer,RecipientSnapshot packet)\r\n\t{\r\n\t\tif (packet == null || packet.Version != 2 || packet.MatchId == Guid.Empty || packet.Revision \u003C 0 || !double.IsFinite(packet.Clock) || packet.Own != null \u0026\u0026 packet.Own.Owner != viewer) return false;\r\n\t\tif (retired.Contains(packet.MatchId) || Latest?.MatchId == packet.MatchId \u0026\u0026 packet.Revision \u003C= Latest.Revision) return false;\r\n\t\tif (Latest?.MatchId != packet.MatchId)\r\n\t\t{\r\n\t\t\tif (Latest != null) retired.Add(Latest.MatchId);\r\n\t\t\tState=new MatchState(packet.Public.Width,packet.Public.Height,0,packet.Public.Settings);\r\n\t\t}\r\n\t\tLatest=packet; State.ApplyReplica(packet.Public,packet.Own); return true;\r\n\t}\r\n}\r\n\r\npublic static class MatchProjection\r\n{\r\n\tpublic static RecipientSnapshot For(MatchState state,RoundMachine rounds,Guid viewer,Guid matchId,long revision,double clock)\r\n\t{\r\n\t\tvar players = state.Players.Values.Select(p =\u003E new PublicPlayer(p.Id,p.Name,p.Coins,p.Vp,p.Hand.Count,\r\n\t\t\trounds.IsBuildReady(p.Id),!state.Settings.EnableContracts || rounds.GetContractView(p.Id)?.Selected != null)).ToArray();\r\n\t\tvar auction = rounds.GetAuctionView(viewer);\r\n\t\t// Spectators still see the public lots, never a participant\u0027s private projection.\r\n\t\tvar publicAuction = auction ?? state.Players.Keys.Select(rounds.GetAuctionView).FirstOrDefault(v =\u003E v != null);\r\n\t\tvar visible = publicAuction == null ? null : new PublicAuction(publicAuction.Id,publicAuction.Round,publicAuction.Number,publicAuction.Cap,publicAuction.Resolving,\r\n\t\t\tpublicAuction.Lots.Select(l =\u003E l.Lot).ToArray(),publicAuction.Players.ToArray());\r\n\t\tvar data = new PublicMatchSnapshot(state.Width,state.Height,state.Settings,state.Round,state.Phase,state.PhaseEndsAt,players,state.Cells().ToArray(),state.Claims.ToArray(),\r\n\t\t\tstate.Goals.Active.ToArray(),state.Goals.History.ToArray(),visible,rounds.LastAuction,rounds.LastPayout,state.ContractReveals.ToArray(),state.FinalScore,state.ActivitySnapshot());\r\n\t\tOwnerMatchSnapshot own = null;\r\n\t\tif (state.Players.TryGetValue(viewer,out var owner))\r\n\t\t{\r\n\t\t\tvar contract = rounds.GetContractView(viewer);\r\n\t\t\t// ContractSession\u0027s internal draft token derives from its hidden seed. The wire token is scoped by MatchId instead.\r\n\t\t\tif (contract != null) contract = contract with { DraftId = matchId.GetHashCode() };\r\n\t\t\town = new(viewer,owner.HandVersion,owner.Hand.ToArray(),contract,auction,state.BuilderCommon(viewer));\r\n\t\t}\r\n\t\treturn new(2,matchId,revision,clock,data,own);\r\n\t}\r\n}\r\n\r\n/// \u003Csummary\u003EAn authenticated transport supplies caller; a packet can never name the acting player.\u003C/summary\u003E\r\npublic enum MatchCommandKind { Bid, Ready, Place, Discard, PlayGrant, Claim, KeepContract, PlayEvent, BuilderDefault, Renovate, HurryAuction, BuyMarketCard }\r\npublic sealed record MatchCommand(Guid MatchId,long Sequence,int Round,MatchPhase Phase,MatchCommandKind Kind,\r\n\tint AuctionId=0,int Slot=-1,long HandVersion=0,string CardId=null,int X=0,int Y=0,int Amount=0,int ReplacementSlot=-1);\r\npublic sealed record CommandReply(long Sequence,bool Accepted,string Reason);\r\ninternal sealed record CommandSequence(Guid Player,long Sequence);\r\n\r\npublic sealed class MatchAuthority\r\n{\r\n\treadonly MatchState state;\r\n\treadonly RoundMachine rounds;\r\n\treadonly Dictionary\u003CGuid,long\u003E sequences = new();\r\n\tpublic Guid MatchId { get; }\r\n\tpublic MatchAuthority(Guid matchId,MatchState state,RoundMachine rounds) { MatchId=matchId; this.state=state; this.rounds=rounds; }\r\n\tinternal long LastSequence(Guid player) =\u003E sequences.GetValueOrDefault(player);\r\n\tinternal CommandSequence[] CaptureSequences() =\u003E sequences.Select(p =\u003E new CommandSequence(p.Key,p.Value)).ToArray();\r\n\tinternal void RestoreSequences(IEnumerable\u003CCommandSequence\u003E previous) { sequences.Clear(); foreach (var p in previous) sequences[p.Player]=p.Sequence; }\r\n\tpublic CommandReply Execute(Guid caller,MatchCommand request,double hostClock)\r\n\t{\r\n\t\tCommandReply Denied(string reason) =\u003E new(request?.Sequence ?? 0,false,reason);\r\n\t\tCommandReply Result(bool accepted,string reason) =\u003E new(request.Sequence,accepted,accepted ? \u0022\u0022 : reason);\r\n\t\tif (request == null || request.MatchId != MatchId || !state.Players.TryGetValue(caller,out var player)) return Denied(\u0022#rules.replication.this_match_or_player_is_no_longer_active\u0022);\r\n\t\tif (request.Sequence \u003C= 0 || request.Sequence \u003C= sequences.GetValueOrDefault(caller)) return Denied(\u0022#rules.replication.request_already_handled\u0022);\r\n\t\tsequences[caller]=request.Sequence;\r\n\t\tif (!double.IsFinite(hostClock) || request.Round != state.Round || request.Phase != state.Phase) return Denied(\u0022#rules.replication.the_phase_has_changed\u0022);\r\n\t\tif (request.Kind == MatchCommandKind.KeepContract)\r\n\t\t\treturn Result(request.AuctionId == MatchId.GetHashCode() \u0026\u0026 rounds.ChooseContract(caller,state.Seed,request.CardId),\u0022#rules.replication.contract_must_be_one_of_your_current_offered_choices\u0022);\r\n\t\tif (request.Kind == MatchCommandKind.BuilderDefault)\r\n\t\t\treturn Result(request.Amount is 0 or 1 \u0026\u0026 state.ChooseBuilderDefault(caller,request.Amount==1),\u0022#rules.replication.choose_a_default_while_you_own_a_builders_yard\u0022);\r\n\t\tif (request.Kind == MatchCommandKind.BuyMarketCard)\r\n\t\t\treturn Result(rounds.BuyMarketCard(caller,request.AuctionId,request.Slot,hostClock),\u0022#rules.replication.card_could_not_be_purchased\u0022);\r\n\t\tif (request.Kind == MatchCommandKind.HurryAuction)\r\n\t\t\treturn Result(rounds.HurryAuction(caller,request.AuctionId,hostClock),\u0022#rules.replication.the_auction_clock_could_not_be_started\u0022);\r\n\t\tif (request.Kind == MatchCommandKind.Ready)\r\n\t\t{\r\n\t\t\tif (state.Phase == MatchPhase.Bid \u0026\u0026 rounds.GetAuctionView(caller)?.Id != request.AuctionId) return Denied(\u0022#rules.replication.the_auction_has_changed\u0022);\r\n\t\t\treturn Result(rounds.Ready(caller,hostClock),\u0022#rules.replication.ready_could_not_be_changed\u0022);\r\n\t\t}\r\n\t\tif (state.Phase != MatchPhase.Build || hostClock \u003E= state.PhaseEndsAt || rounds.IsBuildReady(caller)) return Denied(\u0022#rules.replication.building_is_closed\u0022);\r\n\t\tif (request.HandVersion != player.HandVersion || request.Slot \u003C 0 || request.Slot \u003E= player.Hand.Count || player.Hand[request.Slot] != request.CardId)\r\n\t\t\treturn Denied(\u0022#rules.replication.your_hand_changed_select_the_card_again\u0022);\r\n\t\tif (request.Kind == MatchCommandKind.Renovate)\r\n\t\t{\r\n\t\t\tvar error=state.RenovationError(caller,request.Slot,request.ReplacementSlot,request.X,request.Y);\r\n\t\t\tif (error != null) return Denied(error);\r\n\t\t\treturn Result(state.TryRenovate(caller,request.Slot,request.ReplacementSlot,request.X,request.Y).Success,\u0022#rules.replication.renovation_could_not_be_completed\u0022);\r\n\t\t}\r\n\t\tif (request.Kind is MatchCommandKind.Place or MatchCommandKind.Claim)\r\n\t\t{\r\n\t\t\tvar error=request.Kind==MatchCommandKind.Place ? state.PlacementError(caller,request.CardId,request.X,request.Y) : request.CardId==\u0022land-claim\u0022 ? state.CardPlayError(caller,request.CardId,request.X,request.Y) : \u0022#rules.match_state.select_land_claim_first\u0022;\r\n\t\t\tif (error!=null) return Denied(error); // Network denials go only to the caller, not the host\u0027s local feedback.\r\n\t\t}\r\n\t\tvar accepted = request.Kind switch\r\n\t\t{\r\n\t\t\tMatchCommandKind.Place =\u003E state.TryPlace(caller,request.CardId,request.X,request.Y).Success,\r\n\t\t\tMatchCommandKind.Claim =\u003E state.TryClaim(caller,request.Slot,request.X,request.Y).Success,\r\n\t\t\tMatchCommandKind.Discard =\u003E state.Discard(caller,request.Slot),\r\n\t\t\tMatchCommandKind.PlayGrant =\u003E state.PlayGrant(caller,request.Slot), MatchCommandKind.PlayEvent =\u003E state.PlayEvent(caller,request.Slot), _ =\u003E false\r\n\t\t};\r\n\t\treturn new(request.Sequence,accepted,accepted ? \u0022\u0022 : \u0022#rules.replication.that_action_is_not_valid_here\u0022);\r\n\t}\r\n}\r\n"},{"Ident":"degg.city_sim","Path":"Match/TriggerChecks.cs","FileName":"TriggerChecks.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":443612,"IsPrivate":false,"Code":"using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text.Json;\nusing CitySim.Cards;\n\nnamespace CitySim.Match;\n\npublic static class TriggerChecks\n{\n    static readonly Guid A = new(\u002211111111-1111-1111-1111-111111111111\u0022), B = new(\u002222222222-2222-2222-2222-222222222222\u0022);\n    public static IReadOnlyList\u003Cstring\u003E Run()\n    {\n        var passed = new List\u003Cstring\u003E();\n        void Check(bool ok,string name) { if (!ok) throw new InvalidOperationException(name); passed.Add(\u0022PASS \u0022\u002Bname); }\n        MatchState Board()\n        {\n            var s = new MatchState(12,12,42); s.AddPlayer(A,\u0022A\u0022); s.AddPlayer(B,\u0022B\u0022);\n            for (var x=0;x\u003C12;x\u002B\u002B) s.SeedStreet(x,6);\n            s.SetPhase(MatchPhase.Build,1,40); return s;\n        }\n        void Place(MatchState s,Guid id,string card,int x,int y)\n        {\n            if (!s.GiveCard(id,card) || !s.TryPlace(id,card,x,y).Success) throw new InvalidOperationException($\u0022Setup {card} {x},{y}\u0022);\n        }\n        var circle=Board(); Place(circle,A,\u0022roundabout\u0022,2,5);\n        circle.GiveCard(B,\u0022house\u0022); var before=circle.Players[A].Vp;\n        var hypothetical = new BoardCell(2,4,\u0022house\u0022,B,100,1);\n        Check(PlacementScoring.Calculate(circle,hypothetical).Awards.Any(a =\u003E a.Player==A \u0026\u0026 a.Reason==\u0022#cards.roundabout.name\u0022 \u0026\u0026 a.Vp==1) \u0026\u0026 circle.Players[A].Vp==before,\u0022Roundabout preview awards owner without mutation\u0022);\n        Check(circle.TryPlace(B,\u0022house\u0022,2,4).Success \u0026\u0026 circle.Players[A].Vp==before\u002B1,\u0022rival building awards Roundabout owner\u0022);\n        Place(circle,B,\u0022street\u0022,1,5);\n        Check(circle.Players[A].Vp==before\u002B1,\u0022adjacent road does not trigger Roundabout\u0022);\n        Place(circle,A,\u0022park\u0022,3,5);\n        Check(circle.Players[A].Vp\u003E=before\u002B2 \u0026\u0026 PlacementScoring.Calculate(circle,new BoardCell(3,4,\u0022street\u0022,A,101,1)).Awards.All(a =\u003E a.Reason!=\u0022#cards.roundabout.name\u0022),\u0022own building triggers Roundabout and road preview does not\u0022);\n        var contractor=Board(); Place(contractor,A,\u0022contractor\u0022,0,5);\n        var first=contractor.Players[A].Coins; contractor.GiveCard(A,\u0022house\u0022);\n        for (var i=0;i\u003C3;i\u002B\u002B) PlacementScoring.Preview(contractor,\u0022house\u0022,1,5,A);\n        Check(contractor.TriggerUses(contractor.Get(0,5).Order,1)==0,\u0022previews do not record Contractor triggers\u0022);\n        Check(!contractor.TryPlace(A,\u0022house\u0022,0,5).Success \u0026\u0026 contractor.TriggerUses(contractor.Get(0,5).Order,1)==0,\u0022rejected placement does not record Contractor triggers\u0022);\n        Check(contractor.TryPlace(A,\u0022house\u0022,1,5).Success \u0026\u0026 contractor.Players[A].Coins==first\u002B1,\u0022Contractor pays for own House\u0022);\n        Place(contractor,B,\u0022shop\u0022,2,5); Check(contractor.Players[A].Coins==first\u002B1,\u0022Contractor ignores rival Shop\u0022);\n        Place(contractor,A,\u0022apartment\u0022,3,5); Check(contractor.Players[A].Coins==first\u002B1,\u0022Contractor counts House card, not Apartment\u0022);\n        Place(contractor,A,\u0022shop\u0022,4,5); Place(contractor,A,\u0022house\u0022,5,5);\n        Check(contractor.Players[A].Coins==first\u002B3 \u0026\u0026 contractor.TriggerUses(contractor.Get(0,5).Order,1)==0,\u0022Contractor pays for every own House and Shop without a round cap\u0022);\n        var saved=JsonSerializer.Deserialize\u003CMatchCheckpoint\u003E(JsonSerializer.Serialize(contractor.Capture()));\n        var restored=MatchState.Restore(saved); var restoredCoins=restored.Players[A].Coins;\n        Place(restored,A,\u0022shop\u0022,6,5);\n        Check(restored.Players[A].Coins==restoredCoins\u002B1 \u0026\u0026 restored.TriggerUses(restored.Get(0,5).Order,1)==0,\u0022Contractor keeps paying after checkpoint restore\u0022);\n        restored.SetPhase(MatchPhase.Build,2,80); Place(restored,A,\u0022house\u0022,7,5);\n        Check(restored.Players[A].Coins==restoredCoins\u002B2 \u0026\u0026 restored.TriggerUses(restored.Get(0,5).Order,2)==0,\u0022Contractor keeps paying in the next round\u0022);\n        var rejectedUsage=false;\n        try { MatchState.Restore(saved with { TriggerUsage = new[] { new TriggerUsageCheckpoint(1,1,3) } }); }\n        catch (ArgumentException) { rejectedUsage=true; }\n        Check(rejectedUsage,\u0022checkpoint rejects invalid capped trigger usage\u0022);\n        var legacy=MatchState.Restore(saved with {TriggerUsage=null});\n        Check(legacy.TriggerUses(legacy.Get(0,5).Order,1)==0,\u0022older checkpoint without trigger usage loads with empty usage\u0022);\n        return passed.AsReadOnly();\n    }\n}\n"},{"Ident":"degg.city_sim","Path":"Tiles/CardVisuals.cs","FileName":"CardVisuals.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":443612,"IsPrivate":false,"Code":"using CitySim.Cards;\r\n\r\nnamespace CitySim;\r\n\r\n/// \u003Csummary\u003EPresentation only: card IDs map to imported meshes, independent of match randomness.\u003C/summary\u003E\r\npublic static class CardVisuals\r\n{\r\n\tpublic record Part(string Path, float Size = 160, float X = 0, float Y = 0, float Z = 0);\r\n\tstatic Part C(string name, float size = 160, float x = 0, float y = 0, float z = 0) =\u003E new($\u0022models/kenney/commercial/{name}.vmdl\u0022, size,x,y,z);\r\n\tstatic Part I(string name, float size = 160, float x = 0, float y = 0, float z = 0) =\u003E new($\u0022models/kenney/industrial/{name}.vmdl\u0022, size,x,y,z);\r\n\tstatic Part S(string name, float size = 160, float x = 0, float y = 0, float z = 0) =\u003E new($\u0022models/kenney/suburban/{name}.vmdl\u0022, size,x,y,z);\r\n\tstatic readonly Dictionary\u003Cstring, Part[]\u003E visuals = new()\r\n\t{\r\n\t\t[\u0022house\u0022] = [S(\u0022building-type-a\u0022)], [\u0022cottage\u0022] = [S(\u0022building-type-g\u0022)],\r\n\t\t[\u0022apartment\u0022] = [C(\u0022building-j\u0022)], [\u0022shop\u0022] = [C(\u0022building-c\u0022)],\r\n\t\t[\u0022cafe\u0022] = [C(\u0022building-d\u0022,130,-20,0),C(\u0022detail-parasol-a\u0022,45,65,55)],\r\n\t\t[\u0022market\u0022] = [C(\u0022building-k\u0022,140,-10,0),C(\u0022detail-awning-wide\u0022,55,62,0,35)],\r\n\t\t[\u0022farm\u0022] = [], // Dedicated procedural barn and crop rows below.\r\n\t\t[\u0022orchard\u0022] = [S(\u0022tree-large\u0022,65,-45,-40),S(\u0022tree-large\u0022,65,45,35),S(\u0022planter\u0022,50,40,-45)],\r\n\t\t[\u0022factory\u0022] = [I(\u0022building-e\u0022)], [\u0022park\u0022] = [S(\u0022tree-large\u0022,80,-40,-30),S(\u0022tree-small\u0022,60,45,35),S(\u0022path-stones-long\u0022,110,20,-30)],\r\n\t\t[\u0022wild-garden\u0022] = [S(\u0022planter\u0022,60,-40,-30),S(\u0022tree-small\u0022,70,40,35),S(\u0022path-stones-messy\u0022,90,10,-20)],\r\n\t\t[\u0022sapling\u0022] = [S(\u0022tree-small\u0022,65)], [\u0022forest\u0022] = [S(\u0022tree-large\u0022,75,-45,-35),S(\u0022tree-large\u0022,75,45,30),S(\u0022tree-small\u0022,60,35,-45)],\r\n\t\t[\u0022vineyard\u0022] = [S(\u0022building-type-i\u0022,125,-25,0),S(\u0022planter\u0022,45,65,35)],\r\n\t\t[\u0022startup\u0022] = [C(\u0022building-b\u0022)], [\u0022contractor\u0022] = [I(\u0022building-p\u0022)],\r\n\t\t[\u0022bank\u0022] = [C(\u0022building-i\u0022)], [\u0022town-hall\u0022] = [C(\u0022building-n\u0022)], [\u0022museum\u0022] = [C(\u0022building-e\u0022)],\r\n\t\t[\u0022chamber-of-commerce\u0022] = [C(\u0022building-l\u0022)], [\u0022warehouse\u0022] = [I(\u0022building-j\u0022)],\r\n\t\t[\u0022builders-yard\u0022] = [I(\u0022building-h\u0022,125,-25,0),I(\u0022shipping-container-a\u0022,55,65,40)],\r\n\t\t[\u0022hospital\u0022] = [C(\u0022building-m\u0022)], [\u0022tourist-office\u0022] = [C(\u0022building-e\u0022)], [\u0022tax-office\u0022] = [I(\u0022building-a\u0022)],\r\n\t\t[\u0022depot\u0022] = [I(\u0022building-s\u0022)], [\u0022school\u0022] = [C(\u0022building-k\u0022)],\r\n\t\t[\u0022granary\u0022] = [I(\u0022building-k\u0022,125,-25,0),I(\u0022detail-tank-large\u0022,50,65,40)],\r\n\t\t[\u0022airport\u0022] = [I(\u0022building-g\u0022)], [\u0022skyscraper\u0022] = [C(\u0022building-skyscraper-a\u0022)],\r\n\t\t[\u0022industrial-estate\u0022] = [I(\u0022building-q\u0022)], [\u0022housing-authority\u0022] = [C(\u0022building-f\u0022)],\r\n\t\t[\u0022eco-council\u0022] = [I(\u0022building-t\u0022,135,-15,0),I(\u0022solar-panel-flat\u0022,50,20,0,80)],\r\n\t\t[\u0022gardeners-guild\u0022] = [S(\u0022building-type-i\u0022,125,-25,0),S(\u0022planter\u0022,45,65,35)],\r\n\t\t[\u0022architects-studio\u0022] = [C(\u0022building-h\u0022)], [\u0022central-station\u0022] = [C(\u0022building-n\u0022)],\r\n\t\t[\u0022stadium\u0022] = [], [\u0022fountain\u0022] = [], [\u0022senate\u0022] = [], [\u0022monument\u0022] = [], [\u0022lake\u0022] = [],\r\n\t\t[\u0022street\u0022] = [], [\u0022avenue\u0022] = [], [\u0022roundabout\u0022] = [],\r\n\t\t[\u0022grant\u0022] = [], [\u0022renovation\u0022] = [], [\u0022ribbon-cutting\u0022] = [], [\u0022rush-hour\u0022] = [], [\u0022land-claim\u0022] = []\r\n\t};\r\n\tstatic readonly HashSet\u003Cstring\u003E warned = new();\r\n\tstatic Model fallback;\r\n\tstatic Model lake;\r\n\tstatic Model roundaboutMarker;\r\n\tconst float BuildingFootprint = 0.8f;\r\n\tstatic readonly Dictionary\u003Cstring,Model\u003E farmParts = new();\r\n\tstatic Model Fallback()\r\n\t{\r\n\t\tif (fallback != null) return fallback;\r\n\t\tvar vertices = new VertexBuffer(); vertices.Init(true);\r\n\t\tvertices.AddCube(new Vector3(0,0,55),new Vector3(110,110,110),Rotation.Identity,Color.White);\r\n\t\tvar mesh = new Mesh(Material.Load(\u0022materials/simple/simple_grey_50.vmat\u0022)); mesh.CreateBuffers(vertices);\r\n\t\treturn fallback = new ModelBuilder().AddMesh(mesh).Create();\r\n\t}\r\n\tpublic static void Clear(GameObject root)\r\n\t{\r\n\t\tforeach (var child in root.Children.ToList()) { child.Parent = null; child.Destroy(); }\r\n\t}\r\n\tpublic static void BuildRoad(GameObject root, string path, bool roundabout = false)\r\n\t{\r\n\t\tClear(root);\r\n\t\tvar model = Model.Load(path);\r\n\t\tif (model == null || model.IsError || Math.Max(model.Bounds.Size.x,model.Bounds.Size.y) \u003C= 0) { AddFallback(root,path); return; }\r\n\t\tvar bounds = model.Bounds; var scale = 200f/Math.Max(bounds.Size.x,bounds.Size.y);\r\n\t\tvar child = new GameObject(true,path) { Parent = root }; child.LocalRotation = Rotation.Identity; child.LocalScale = scale;\r\n\t\tchild.LocalPosition = -new Vector3(bounds.Center.x,bounds.Center.y,bounds.Mins.z)*scale;\r\n\t\tchild.Components.Create\u003CModelRenderer\u003E().Model = model;\r\n\t\tif (roundabout)\r\n\t\t{\r\n\t\t\tvar marker = new GameObject(true,\u0022Roundabout marker\u0022) { Parent = root }; marker.LocalRotation = Rotation.Identity; marker.LocalPosition = Vector3.Up*4;\r\n\t\t\tif (roundaboutMarker == null)\r\n\t\t\t{\r\n\t\t\t\tvar vertices = new VertexBuffer(); vertices.Init(true);\r\n\t\t\t\tfor (var i = 0; i \u003C 12; i\u002B\u002B)\r\n\t\t\t\t{\r\n\t\t\t\t\tvar angle = i * MathF.PI * 2 / 12;\r\n\t\t\t\t\tvertices.AddCube(new Vector3(MathF.Cos(angle)*25,MathF.Sin(angle)*25,3),new Vector3(15,8,6),Rotation.FromYaw(i*30\u002B90),Color.White);\r\n\t\t\t\t}\r\n\t\t\t\tvar mesh = new Mesh(Material.Load(\u0022materials/simple/simple_grey_50.vmat\u0022)); mesh.CreateBuffers(vertices);\r\n\t\t\t\troundaboutMarker = new ModelBuilder().AddMesh(mesh).Create();\r\n\t\t\t}\r\n\t\t\tmarker.Components.Create\u003CModelRenderer\u003E().Model = roundaboutMarker;\r\n\t\t}\r\n\t}\r\n\tpublic static void Build(GameObject root, string id, bool mature = false, int visualSeed = 0)\r\n\t{\r\n\t\tClear(root);\r\n\t\tvar key = id == \u0022sapling\u0022 \u0026\u0026 mature ? \u0022forest\u0022 : id;\r\n\t\tif (key == \u0022farm\u0022) { AddFarm(root); return; }\r\n\t\tif (key == \u0022lake\u0022) { AddLake(root); return; }\r\n\t\tif (!visuals.TryGetValue(key, out var parts) || parts.Length == 0) { AddFallback(root,key); return; }\r\n\t\tvar layout = Layout(parts,key,visualSeed);\r\n\t\tforeach (var pose in layout)\r\n\t\t{\r\n\t\t\tvar part = pose.Part;\r\n\t\t\tvar model = Model.Load(part.Path);\r\n\t\t\tif (model == null || model.IsError) { AddFallback(root,part.Path); continue; }\r\n\t\t\tvar bounds = model.Bounds;\r\n\t\t\tvar extent = Math.Max(bounds.Size.x,bounds.Size.y);\r\n\t\t\tif (extent \u003C= 0) { AddFallback(root,part.Path); continue; }\r\n\t\t\tvar scale = part.Size/extent * BuildingFootprint * pose.Scale;\r\n\t\t\tvar child = new GameObject(true,part.Path) { Parent = root };\r\n\t\t\tchild.LocalRotation = Rotation.FromYaw(pose.Yaw); child.LocalScale = scale;\r\n\t\t\tchild.LocalPosition = new Vector3(pose.X,pose.Y,part.Z)*BuildingFootprint \u002B Vector3.Up*15 - child.LocalRotation * (new Vector3(bounds.Center.x,bounds.Center.y,bounds.Mins.z)*scale);\r\n\t\t\tchild.Components.Create\u003CModelRenderer\u003E().Model = model;\r\n\t\t}\r\n\t}\r\n\r\n\treadonly record struct Pose(Part Part, float X, float Y, float Scale = 1, float Yaw = 0);\r\n\tstatic bool Tree(Part part) =\u003E part.Path.Contains(\u0022/tree-\u0022);\r\n\tstatic bool GroundPart(Part part) =\u003E part.Path.Contains(\u0022/path-stones\u0022);\r\n\tstatic bool Movable(Part part) =\u003E Tree(part) || part.Path.Contains(\u0022/planter.\u0022) || part.Path.Contains(\u0022/detail-parasol-\u0022) || part.Path.Contains(\u0022/shipping-container-\u0022) || part.Path.Contains(\u0022/detail-tank-\u0022);\r\n\r\n\tstatic Pose[] Layout(Part[] parts, string key, int seed)\r\n\t{\r\n\t\tvar random = new Random(seed);\r\n\t\tfloat Range(float min,float max) =\u003E min\u002B(max-min)*(float)random.NextDouble();\r\n\t\t// Retry the complete arrangement: props stay in their authored zones and must\r\n\t\t// clear each other and the tile edge. Paths intentionally run beneath foliage.\r\n\t\tfor (var attempt=0; attempt\u003C32; attempt\u002B\u002B)\r\n\t\t{\r\n\t\t\tvar poses = parts.Select(part =\u003E\r\n\t\t\t{\r\n\t\t\t\tif (!Movable(part)) return new Pose(part,part.X,part.Y);\r\n\t\t\t\tvar y = part.Y;\r\n\t\t\t\t// Side yards can occupy either end; facade and rooftop attachments stay fixed.\r\n\t\t\t\tif (!Tree(part) \u0026\u0026 key is \u0022cafe\u0022 or \u0022builders-yard\u0022 or \u0022granary\u0022 or \u0022vineyard\u0022 or \u0022gardeners-guild\u0022) y = random.Next(2)==0 ? -45 : 45;\r\n\t\t\t\treturn new Pose(part,part.X\u002BRange(-9,9),y\u002BRange(-9,9),Tree(part) ? Range(0.94f,1.06f) : 1,Tree(part) || part.Path.Contains(\u0022/detail-parasol-\u0022) ? Range(0,360) : 0);\r\n\t\t\t}).ToArray();\r\n\t\t\tvar boxes = new List\u003C(float X,float Y,float HalfX,float HalfY,bool Ground,bool Movable)\u003E();\r\n\t\t\tvar valid = true;\r\n\t\t\tforeach (var pose in poses)\r\n\t\t\t{\r\n\t\t\t\tvar model = Model.Load(pose.Part.Path);\r\n\t\t\t\tif (model == null || model.IsError) continue;\r\n\t\t\t\tvar size = model.Bounds.Size;\r\n\t\t\t\tvar extent = Math.Max(size.x,size.y);\r\n\t\t\t\tif (extent \u003C= 0) continue;\r\n\t\t\t\tvar scale = pose.Part.Size/extent*pose.Scale;\r\n\t\t\t\tvar angle = pose.Yaw*MathF.PI/180;\r\n\t\t\t\tvar c = MathF.Abs(MathF.Cos(angle)); var s = MathF.Abs(MathF.Sin(angle));\r\n\t\t\t\tvar hx = (c*size.x\u002Bs*size.y)*scale*0.5f;\r\n\t\t\t\tvar hy = (s*size.x\u002Bc*size.y)*scale*0.5f;\r\n\t\t\t\tvar ground = GroundPart(pose.Part); var movable = Movable(pose.Part);\r\n\t\t\t\tvar edgeX = MathF.Abs(pose.X)\u002Bhx; var edgeY = MathF.Abs(pose.Y)\u002Bhy;\r\n\t\t\t\t// Free-rotating forest clusters must fit even at diagonal root angles.\r\n\t\t\t\tif (edgeX\u003E112 || edgeY\u003E112 || (key is \u0022forest\u0022 or \u0022sapling\u0022) \u0026\u0026 edgeX*edgeX\u002BedgeY*edgeY\u003E122*122) { valid=false; break; }\r\n\t\t\t\tif (!ground \u0026\u0026 boxes.Any(b =\u003E !b.Ground \u0026\u0026 (movable || b.Movable) \u0026\u0026 MathF.Abs(pose.X-b.X)\u003Chx\u002Bb.HalfX\u002B3 \u0026\u0026 MathF.Abs(pose.Y-b.Y)\u003Chy\u002Bb.HalfY\u002B3)) { valid=false; break; }\r\n\t\t\t\tboxes.Add((pose.X,pose.Y,hx,hy,ground,movable));\r\n\t\t\t}\r\n\t\t\tif (valid) return poses;\r\n\t\t}\r\n\t\t// The original composition is the safe fallback for unusually shaped assets.\r\n\t\treturn parts.Select(part =\u003E new Pose(part,part.X,part.Y)).ToArray();\r\n\t}\r\n\r\n\tstatic void AddFarm(GameObject root)\r\n\t{\r\n\t\tvoid Part(string name, Action\u003CVertexBuffer\u003E geometry)\r\n\t\t{\r\n\t\t\tif (!farmParts.TryGetValue(name,out var model))\r\n\t\t\t{\r\n\t\t\t\tvar v = new VertexBuffer(); v.Init(true); geometry(v);\r\n\t\t\t\tvar mesh = new Mesh(Material.Load(\u0022materials/simple/simple_grey_50.vmat\u0022)); mesh.CreateBuffers(v);\r\n\t\t\t\tmodel = new ModelBuilder().AddMesh(mesh).Create(); farmParts[name] = model;\r\n\t\t\t}\r\n\t\t\tvar child = new GameObject(true,name) { Parent = root }; child.LocalRotation = Rotation.Identity;\r\n\t\t\tchild.LocalPosition = Vector3.Up*15; child.LocalScale = BuildingFootprint;\r\n\t\t\tchild.Components.Create\u003CModelRenderer\u003E().Model = model;\r\n\t\t}\r\n\t\tPart(\u0022Farm soil\u0022,v =\u003E v.AddCube(new Vector3(35,0,2),new Vector3(90,150,4),Rotation.Identity,Color.White));\r\n\t\tPart(\u0022Farm crops\u0022,v =\u003E {\r\n\t\t\tfor(int row=0;row\u003C5;row\u002B\u002B) for(int col=0;col\u003C4;col\u002B\u002B)\r\n\t\t\t\tv.AddCube(new Vector3(4\u002Bcol*20,-58\u002Brow*28,9),new Vector3(10,18,14),Rotation.Identity,Color.White);\r\n\t\t});\r\n\t\tPart(\u0022Farm barn\u0022,v =\u003E v.AddCube(new Vector3(-52,0,26),new Vector3(60,90,52),Rotation.Identity,Color.White));\r\n\t\tPart(\u0022Farm roof\u0022,v =\u003E {\r\n\t\t\tv.AddCube(new Vector3(-69,0,61),new Vector3(44,102,7),Rotation.From(new Angles(-32,0,0)),Color.White);\r\n\t\t\tv.AddCube(new Vector3(-35,0,61),new Vector3(44,102,7),Rotation.From(new Angles(32,0,0)),Color.White);\r\n\t\t});\r\n\t\tPart(\u0022Farm doors\u0022,v =\u003E v.AddCube(new Vector3(-52,-46,18),new Vector3(28,2,36),Rotation.Identity,Color.White));\r\n\t}\r\n\tstatic Color FarmColor(string name) =\u003E name switch {\r\n\t\t\u0022Farm soil\u0022 =\u003E new Color(0.42f,0.24f,0.1f), \u0022Farm crops\u0022 =\u003E new Color(0.4f,0.75f,0.12f),\r\n\t\t\u0022Farm barn\u0022 =\u003E new Color(0.85f,0.18f,0.1f), \u0022Farm roof\u0022 =\u003E new Color(0.25f,0.15f,0.12f), _ =\u003E Color.White };\r\n\tstatic void AddLake(GameObject root)\r\n\t{\r\n\t\tif (lake == null)\r\n\t\t{\r\n\t\t\tvar vertices = new VertexBuffer(); vertices.Init(true);\r\n\t\t\tvertices.AddCube(new Vector3(0,0,3),new Vector3(170,170,6),Rotation.Identity,Color.White);\r\n\t\t\tvar mesh = new Mesh(Material.Load(\u0022materials/simple/simple_grey_50.vmat\u0022)); mesh.CreateBuffers(vertices);\r\n\t\t\tlake = new ModelBuilder().AddMesh(mesh).Create();\r\n\t\t}\r\n\t\tvar child = new GameObject(true,\u0022Lake\u0022) { Parent = root }; child.LocalPosition = Vector3.Up*15;\r\n\t\tchild.Components.Create\u003CModelRenderer\u003E().Model = lake;\r\n\t}\r\n\tstatic void AddFallback(GameObject root, string key)\r\n\t{\r\n\t\tif (warned.Add(key)) Log.Warning($\u0022Missing card visual: {key}\u0022);\r\n\t\tvar child = new GameObject(true,\u0022Missing visual\u0022) { Parent = root };\r\n\t\tchild.LocalPosition = Vector3.Up*15;\r\n\t\tchild.Components.Create\u003CModelRenderer\u003E().Model = Fallback();\r\n\t}\r\n\tpublic static void Tint(GameObject root, Color tint)\r\n\t{\r\n\t\tforeach (var child in root.Children) { var renderer = child.Components.Get\u003CModelRenderer\u003E(); if (renderer != null) renderer.Tint = child.Name.StartsWith(\u0022Farm \u0022) ? tint * FarmColor(child.Name) : child.Name == \u0022Missing visual\u0022 ? tint * new Color(1,0.05f,0.8f) : child.Name == \u0022Lake\u0022 ? tint * new Color(0.2f,0.55f,1) : child.Name == \u0022Roundabout marker\u0022 ? tint * new Color(0.5f,0.85f,0.25f) : tint; }\r\n\t}\r\n}\r\n\r\n\r\n"},{"Ident":"degg.city_sim","Path":"Ui/CardRuleContent.razor","FileName":"CardRuleContent.razor","PackageType":"game","CodeKind":"Game","AssetVersionId":443612,"IsPrivate":false,"Code":"@namespace CitySim\r\n@using CitySim.Cards\r\n@using CitySim.Match\r\n@using CitySim.DesignSystem\r\n@using CitySim.Punchboard\r\n@using Sandbox.UI\r\n@inherits Panel\r\n\r\n\u003Croot class=\u0022card-rule-content\u0022\u003E\r\n @foreach (var section in CardRuleText.Split(Text)) {\r\n  @if (section.Label != null) { \u003CPbRuleKeyword CssClass=@(section.LabelCssClass) Label=@(GameText.Plain(section.Label)) Help=@(GameText.Plain(section.Help)) /\u003E }\r\n  @foreach (var word in CardRuleText.Words(section.Text)) {\r\n   @if (word.LineBreak) { \u003Cdiv class=\u0022rule-line-break\u0022\u003E\u003C/div\u003E }\r\n   else if (word.Interactive) {\r\n    \u003CPlotterCardReference City=@City Label=@word.Text CssClass=@word.CssClass Tag=@word.Tag Targets=@(WordTargets(word)) ReferenceChanged=@ReferenceChanged TargetsChanged=@TargetsChanged /\u003E\r\n   }\r\n   else { \u003Cspan class=@word.CssClass\u003E@word.Text\u003C/span\u003E }\r\n  }\r\n }\r\n\u003C/root\u003E\r\n@code {\r\n [Parameter] public CityGame City { get; set; }\r\n [Parameter] public string Text { get; set; }\r\n [Parameter] public Action\u003CTag\u003E ReferenceChanged { get; set; }\r\n [Parameter] public Action\u003CIReadOnlyList\u003CInteractionTarget\u003E\u003E TargetsChanged { get; set; }\r\n static IReadOnlyList\u003CInteractionTarget\u003E WordTargets(CardRuleText.Word word) =\u003E new[] {\r\n  new InteractionTarget(word.Text, word.Park ? InteractionKind.Park : word.Shop ? InteractionKind.Shop : word.Kind, CardId: word.CardId)\r\n };\r\n protected override int BuildHash() =\u003E HashCode.Combine(GameText.LanguageCode, Text, City, ReferenceChanged, TargetsChanged);\r\n}\r\n"},{"Ident":"degg.city_sim","Path":"Ui/DesignSystem/PlotterCard.razor","FileName":"PlotterCard.razor","PackageType":"game","CodeKind":"Game","AssetVersionId":443612,"IsPrivate":false,"Code":"@namespace CitySim.DesignSystem\r\n@using CitySim.Cards\r\n@using CitySim.Match\r\n@using CitySim.Punchboard\r\n@using Sandbox.UI\r\n@inherits Panel\r\n\u003Croot class=\u0022plotter-card @CssClass rarity-@(Card?.Rarity.ToString().ToLowerInvariant() ?? \u0022common\u0022) @(Compact ? \u0022compact\u0022 : \u0022\u0022) @(Selected ? \u0022selected\u0022 : \u0022\u0022) @(Disabled ? \u0022disabled\u0022 : \u0022\u0022) @(Matches ? \u0022reference-match\u0022 : \u0022\u0022)\u0022\u003E\r\n@if (Card != null) {\r\n \u003Cdiv class=\u0022plotter-card-paper\u0022\u003E\u003C/div\u003E@if (Card.Rarity == Rarity.Legendary) { \u003Cdiv class=\u0022plotter-card-foil-window\u0022\u003E\u003Cdiv class=\u0022plotter-card-foil\u0022\u003E\u003C/div\u003E\u003C/div\u003E }\u003Cdiv class=\u0022plotter-card-rarity-frame\u0022\u003E\u003C/div\u003E\u003Cdiv class=\u0022plotter-card-rarity-inner\u0022\u003E\u003C/div\u003E\r\n @if (!string.IsNullOrEmpty(HotKey)) { \u003Cdiv class=\u0022plotter-card-key\u0022\u003E@HotKey\u003C/div\u003E }\r\n @if (DisplayCost.HasValue) { \u003Cdiv class=\u0022plotter-card-cost\u0022\u003E\uD83E\uDE99 @DisplayCost.Value\u003C/div\u003E }\r\n \u003Cdiv class=\u0022plotter-card-tags\u0022\u003E@foreach (var tag in TagChip.Values(Card.Tags)) { \u003CPlotterCardReference City=@City Label=@($\u0022{TagChip.Icon(tag)} {GameText.EnumLabel(tag)}\u0022) Tag=@tag ReferenceChanged=@ReferenceChanged /\u003E }\u003C/div\u003E\r\n \u003Cdiv class=\u0022plotter-card-art\u0022\u003E\u003CPbIcon Icon=@(CardPresentation.Artwork(Card, Mature)) /\u003E\u003C/div\u003E\r\n \u003Cdiv class=\u0022plotter-card-name\u0022\u003E@(GameText.Plain(Card.Name))\u003C/div\u003E\r\n @if (!BuildOnly \u0026\u0026 ActualBuildCost \u003E 0) { \u003Cdiv class=\u0022plotter-card-build\u0022\u003E@(GameText.Get(\u0022ui.plotter_card.to_build_total\u0022, ActualBuildCost, (DisplayCost ?? 0) \u002B ActualBuildCost))\u003C/div\u003E }\r\n \u003Cdiv class=\u0022plotter-card-rules\u0022\u003E@foreach (var part in CardRuleText.Split(CardPresentation.Rules(Card))) {\r\n  \u003Cdiv class=\u0022plotter-card-rule\u0022\u003E@if (part.Label != null) { \u003CPbRuleKeyword CssClass=@part.LabelCssClass Label=@(GameText.Plain(part.Label)) Help=@(GameText.Plain(part.Help)) /\u003E }\r\n  \u003Cdiv class=\u0022plotter-card-words\u0022\u003E\u003CCardRuleContent City=@City Text=@part.Text ReferenceChanged=@ReferenceChanged TargetsChanged=@TargetsChanged /\u003E\u003C/div\u003E\u003C/div\u003E\r\n }\u003C/div\u003E\r\n @if (!string.IsNullOrEmpty(Requirement)) { \u003Cdiv class=\u0022plotter-card-requirement\u0022\u003E@(GameText.Plain(Requirement))\u003C/div\u003E }\r\n \u003Cdiv class=\u0022plotter-card-footer\u0022\u003E\u003CPbRarityMarker Rarity=@(PunchboardMapping.Rarity(Card.Rarity)) /\u003E\r\n  @if (ShowDiscard) { \u003CPlotterButton Label=@(GameText.Get(\u0022ui.plotter_card.discard\u0022)) Variant=\u0022quiet\u0022 Disabled=@(Disabled || DiscardDisabled) Clicked=@Discard /\u003E }\r\n  @if (!string.IsNullOrEmpty(CardPresentation.Limit(Card))) { \u003Cdiv class=\u0022plotter-card-limit\u0022\u003E@CardPresentation.Limit(Card)\u003C/div\u003E }\r\n \u003C/div\u003E\r\n}\r\n\u003C/root\u003E\r\n@code {\r\n [Parameter] public string CssClass { get; set; }\r\n [Parameter] public CardDef Card { get; set; }\r\n [Parameter] public bool Mature { get; set; }\r\n [Parameter] public CityGame City { get; set; }\r\n [Parameter] public int? Cost { get; set; }\r\n [Parameter] public int? BuildCost { get; set; }\r\n [Parameter] public bool BuildOnly { get; set; }\r\n [Parameter] public bool Compact { get; set; }\r\n [Parameter] public bool Selected { get; set; }\r\n [Parameter] public bool Disabled { get; set; }\r\n [Parameter] public string HotKey { get; set; }\r\n [Parameter] public bool ShowDiscard { get; set; }\r\n [Parameter] public bool DiscardDisabled { get; set; }\r\n [Parameter] public bool SelectionSound { get; set; } = true;\r\n [Parameter] public Action Clicked { get; set; }\r\n [Parameter] public Action Discarded { get; set; }\r\n [Parameter] public Tag ReferenceTag { get; set; }\r\n [Parameter] public Action\u003CTag\u003E ReferenceChanged { get; set; }\r\n [Parameter] public Action\u003CIReadOnlyList\u003CInteractionTarget\u003E\u003E TargetsChanged { get; set; }\r\n int ActualBuildCost =\u003E BuildCost ?? (City?.Match?.Settings ?? new MatchSettings()).BuildCost(Card);\r\n int? DisplayCost =\u003E BuildOnly ? (ActualBuildCost \u003E 0 ? ActualBuildCost : null) : Cost ?? (City?.Match?.Settings ?? new MatchSettings()).Rarities.FirstOrDefault(r =\u003E r.Rarity == Card.Rarity)?.MinimumBid;\r\n string Requirement =\u003E CardPresentation.Requirement(Card);\r\n bool Matches =\u003E Card != null \u0026\u0026 ((ReferenceTag != Tag.None \u0026\u0026 Card.Tags.HasFlag(ReferenceTag)) || (City?.ReferenceTag is { } tag \u0026\u0026 tag != Tag.None \u0026\u0026 Card.Tags.HasFlag(tag)));\r\n protected override void OnParametersSet() { base.OnParametersSet(); AcceptsFocus = !Disabled \u0026\u0026 Clicked != null; }\r\n protected override void OnClick(MousePanelEvent e) { base.OnClick(e); if (!Descendants.OfType\u003CPlotterButton\u003E().Any(b =\u003Eb.HasHovered) \u0026\u0026 !Descendants.OfType\u003CPlotterCardReference\u003E().Any(b =\u003E b.HasHovered)) Activate(); }\r\n public override void OnButtonEvent(ButtonEvent e) { base.OnButtonEvent(e); if (HasFocus \u0026\u0026 !FocusedChild \u0026\u0026 e.Pressed \u0026\u0026 (e.Button == \u0022enter\u0022 || e.Button == \u0022space\u0022)) Activate(); }\r\n bool FocusedChild =\u003E Descendants.Any(p =\u003E (p is PlotterCardReference || p is PlotterButton) \u0026\u0026 p.HasFocus);\r\n float lastActivation = -100;\r\n public void Activate() { if (Disabled || Clicked == null || lastActivation == Time.Now) return; lastActivation = Time.Now; if (SelectionSound) TabletopAudio.Card(); Clicked(); }\r\n void Discard() { if (Disabled || DiscardDisabled || lastActivation == Time.Now) return; lastActivation = Time.Now; Discarded?.Invoke(); }\r\n static int foilSequence;\r\n readonly float foilOffset = (\u002B\u002BfoilSequence * 0.47f) % 2.4f;\r\n Panel foilPanel;\r\n float foilPhase = -1f;\r\n bool foilEngaged;\r\n float foilBoostAge = -1f;\r\n public override void Tick() {\r\n  base.Tick(); SetClass(\u0022reference-match\u0022, Matches);\r\n  if (Card?.Rarity != Rarity.Legendary) return;\r\n  if (foilPanel == null || foilPanel.IsDeleting) foilPanel = Descendants.FirstOrDefault(p =\u003E p.HasClass(\u0022plotter-card-foil\u0022));\r\n  if (foilPanel == null) return;\r\n  var engaged = HasHovered || HasFocus;\r\n  var justEngaged = engaged \u0026\u0026 !foilEngaged;\r\n  foilEngaged = engaged;\r\n  if (PlayerPreferences.Current.ReduceFlashing) {\r\n   foilBoostAge = -1f;\r\n   foilPanel.Style.Set(\u0022transform\u0022, \u0022translateX(0px)\u0022); foilPanel.Style.Opacity = 0.55f;\r\n   return;\r\n  }\r\n  if (foilPhase \u003C 0f) foilPhase = foilOffset;\r\n  if (justEngaged) {\r\n   // The gap is invisible; an active pass stays in place and brightens smoothly.\r\n   if (foilPhase \u003E= 1.8f) foilPhase = 0f;\r\n   foilBoostAge = 0f;\r\n  }\r\n  foilPhase \u002B= Time.Delta;\r\n  if (foilPhase \u003E= 2.4f) foilPhase %= 2.4f;\r\n  if (foilPhase \u003E= 1.8f) foilBoostAge = -1f;\r\n  if (foilBoostAge \u003E= 0f) foilBoostAge \u002B= Time.Delta;\r\n  var t = Math.Clamp(foilPhase / 1.8f, 0f, 1f);\r\n  var eased = t * t * t * (t * (t * 6f - 15f) \u002B 10f);\r\n  var envelope = MathF.Sin(t * MathF.PI);\r\n  var boost = foilBoostAge \u003C 0f ? 0f : Math.Clamp(foilBoostAge / 0.1f, 0f, 1f);\r\n  boost = boost * boost * (3f - 2f * boost);\r\n  foilPanel.Style.Set(\u0022transform\u0022, FormattableString.Invariant($\u0022translateX({-350f \u002B 700f * eased:0.##}px)\u0022));\r\n  foilPanel.Style.Opacity = envelope * envelope * (0.72f \u002B 0.28f * boost);\r\n }\r\n protected override int BuildHash() =\u003E HashCode.Combine(GameText.LanguageCode, Mature, HashCode.Combine(Card, Cost, BuildCost, BuildOnly, Compact, Selected, Disabled, HashCode.Combine(HotKey, ShowDiscard, DiscardDisabled, ReferenceTag, CssClass)));\r\n}\r\n"}]}