feat: Add initial code

This commit is contained in:
2025-03-02 17:23:42 +01:00
parent 699c061192
commit aa994f6fd3
68 changed files with 10913 additions and 117 deletions

16
backend/backend.sln Executable file
View File

@@ -0,0 +1,16 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "backend", "backend\backend.csproj", "{982660A2-7968-4EE8-88F4-F438910EED70}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{982660A2-7968-4EE8-88F4-F438910EED70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{982660A2-7968-4EE8-88F4-F438910EED70}.Debug|Any CPU.Build.0 = Debug|Any CPU
{982660A2-7968-4EE8-88F4-F438910EED70}.Release|Any CPU.ActiveCfg = Release|Any CPU
{982660A2-7968-4EE8-88F4-F438910EED70}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

5
backend/backend/.env Executable file
View File

@@ -0,0 +1,5 @@
DB_USER=postgres
DB_PASS=postgres
DB_HOST=localhost
DB_PORT=5432
DB_NAME=SetGame

View File

@@ -0,0 +1,134 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using backend.Models;
namespace backend.Controllers
{
[Route("/api/v1/[controller]")]
[ApiController]
public class GamesController : ControllerBase {
private readonly GameContext _context;
public GamesController(GameContext context) {
_context = context;
}
// GET: api/Games
[HttpGet]
public async Task<ActionResult<IEnumerable<Game>>> GetGames() {
return await _context.Games.ToListAsync();
}
// GET: api/Games/5
[HttpGet("{id}")]
public async Task<ActionResult<Game>> GetGame(long id)
{
var game = await _context.Games.FindAsync(id);
if (game == null)
{
return NotFound();
}
return game;
}
// PUT: api/Games/5
// To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
[HttpPut("{id}")]
public async Task<IActionResult> PutGame(long id, Game game)
{
if (id != game.Id)
{
return BadRequest();
}
_context.Entry(game).State = EntityState.Modified;
try {
await _context.SaveChangesAsync();
} catch (DbUpdateConcurrencyException) {
if (!GameExists(id)) {
return NotFound();
} else {
throw;
}
}
return NoContent();
}
// POST: api/Games
// To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
[HttpPost]
public async Task<ActionResult<Game>> PostGame() {
var newGame = new Game {
Deck = (
from shape in Enum.GetValues(typeof(CardShape)).Cast<CardShape>()
from color in Enum.GetValues(typeof(CardColor)).Cast<CardColor>()
from count in Enum.GetValues(typeof(CardCount)).Cast<CardCount>()
from shade in Enum.GetValues(typeof(CardShade)).Cast<CardShade>()
select new Card {
Shape = shape,
Color = color,
Count = count,
Shade = shade
}.ToUshort()).ToArray()
};
newGame.ShuffleDeck();
newGame.DealHand();
_context.Games.Add(newGame);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(GetGame), new { id = newGame.Id }, newGame);
}
// DELETE: api/Games/5
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteGame(long id) {
var game = await _context.Games.FindAsync(id);
if (game == null)
{
return NotFound();
}
_context.Games.Remove(game);
await _context.SaveChangesAsync();
return NoContent();
}
private bool GameExists(long id) {
return _context.Games.Any(e => e.Id == id);
}
[HttpPost]
[Route("[action]/{id}")]
public async Task<ActionResult<SetCheckResult>> CheckSet(
long id,
[FromBody] ushort[] cardIndices
) {
var game = await _context.Games.FindAsync(id);
if (game == null) {
return NotFound();
}
var res = game.IsSet(cardIndices);
if (res == null) {
return BadRequest();
}
await _context.SaveChangesAsync();
return res;
}
}
}

View File

@@ -0,0 +1,54 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using backend.Models;
#nullable disable
namespace backend.Migrations
{
[DbContext(typeof(GameContext))]
[Migration("20250226132916_initial")]
partial class initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("backend.Models.Game", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.PrimitiveCollection<int[]>("Deck")
.HasColumnType("integer[]");
b.PrimitiveCollection<int[]>("Hand")
.HasColumnType("integer[]");
b.Property<DateTime>("StartedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.HasKey("Id");
b.ToTable("Games");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,38 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace backend.Migrations
{
/// <inheritdoc />
public partial class initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Games",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
StartedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
Hand = table.Column<int[]>(type: "integer[]", nullable: true),
Deck = table.Column<int[]>(type: "integer[]", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Games", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Games");
}
}
}

View File

@@ -0,0 +1,57 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using backend.Models;
#nullable disable
namespace backend.Migrations
{
[DbContext(typeof(GameContext))]
[Migration("20250226135631_FailCount")]
partial class FailCount
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("backend.Models.Game", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.PrimitiveCollection<int[]>("Deck")
.HasColumnType("integer[]");
b.Property<int>("Fails")
.HasColumnType("integer");
b.PrimitiveCollection<int[]>("Hand")
.HasColumnType("integer[]");
b.Property<DateTime>("StartedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.HasKey("Id");
b.ToTable("Games");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace backend.Migrations
{
/// <inheritdoc />
public partial class FailCount : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Fails",
table: "Games",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Fails",
table: "Games");
}
}
}

View File

@@ -0,0 +1,60 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using backend.Models;
#nullable disable
namespace backend.Migrations
{
[DbContext(typeof(GameContext))]
[Migration("20250301150105_FinishedAt")]
partial class FinishedAt
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("backend.Models.Game", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.PrimitiveCollection<int[]>("Deck")
.HasColumnType("integer[]");
b.Property<int>("Fails")
.HasColumnType("integer");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<int[]>("Hand")
.HasColumnType("integer[]");
b.Property<DateTime>("StartedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.HasKey("Id");
b.ToTable("Games");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace backend.Migrations
{
/// <inheritdoc />
public partial class FinishedAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "FinishedAt",
table: "Games",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FinishedAt",
table: "Games");
}
}
}

View File

@@ -0,0 +1,57 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using backend.Models;
#nullable disable
namespace backend.Migrations
{
[DbContext(typeof(GameContext))]
partial class GameContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("backend.Models.Game", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.PrimitiveCollection<int[]>("Deck")
.HasColumnType("integer[]");
b.Property<int>("Fails")
.HasColumnType("integer");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<int[]>("Hand")
.HasColumnType("integer[]");
b.Property<DateTime>("StartedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.HasKey("Id");
b.ToTable("Games");
});
#pragma warning restore 612, 618
}
}
}

31
backend/backend/Models/Card.cs Executable file
View File

@@ -0,0 +1,31 @@
namespace backend.Models;
// 3, 7 and 8 are chosen due to them in no way,
// shape or form adding up or substracting into eachother
public enum CardCount { One = 0x0003, Two = 0x0007, Three = 0x0008 }
public enum CardColor { Red = 0x0030, Green = 0x0070, Blue = 0x0080 }
public enum CardShape { Oval = 0x0300, Diamond = 0x0700, Squiggle = 0x0800 }
public enum CardShade { Transparent = 0x3000, Solid = 0x7000, Opaque = 0x8000 }
public struct Card {
public CardCount Count { get; set; }
public CardColor Color { get; set; }
public CardShape Shape { get; set; }
public CardShade Shade { get; set; }
public readonly ushort ToUshort() {
return (ushort)(0x0000 | Convert.ToUInt16(Count)
| Convert.ToUInt16(Color)
| Convert.ToUInt16(Shape)
| Convert.ToUInt16(Shade));
}
public static Card ToCard(int card) {
return new Card {
Count = (CardCount)(card & 0x000F),
Color = (CardColor)(card & 0x00F0),
Shape = (CardShape)(card & 0x0F00),
Shade = (CardShade)(card & 0xF000)
};
}
}

97
backend/backend/Models/Game.cs Executable file
View File

@@ -0,0 +1,97 @@
namespace backend.Models;
public struct SetCheckResult {
public bool IsSet { get; set; }
public Game NewState { get; set; }
}
public class Game {
public long Id { get; set; }
public DateTime StartedAt { get; set; }
public DateTime? FinishedAt { get; set; }
public int Fails { get; set; }
public ushort[]? Hand { get; set; }
public ushort[]? Deck { get; set; }
public void ShuffleDeck() {
if (Deck == null) return;
Random rand = new Random();
Deck = Deck.OrderBy(_ => rand.Next()).ToArray();
}
public void DealHand() {
if (Deck == null) return;
Hand ??= [];
List<ushort> handList = [.. Hand];
while (handList.Count < 12 && Deck.Length > 0) {
handList.Add(Deck[0]);
Deck = [.. Deck.Skip(1)];
}
Hand = [.. handList];
}
public void ReplaceCardInHand(int index) {
if (Hand == null || Deck == null) return;
var handList = Hand.ToList();
Console.WriteLine("Deck Length: " + Deck.Length);
if (Deck.Length < 1) {
handList[index] = 0;
Hand = [.. handList];
return;
}
handList[index] = Deck[0];
Deck = [.. Deck.Skip(1)];
Hand = [.. handList];
}
public bool GameIsFinished() {
if (Deck == null || Hand == null) return false;
var finished = Deck.Length == 0 && Hand.All(card => card == 0);
if (finished) Hand = [];
return finished;
}
public SetCheckResult? IsSet(ushort[] indices) {
if (indices.Length != 3 || Hand == null) return null;
bool AnyEmpty = indices.Any(index => Hand[index] == 0);
if (AnyEmpty) return null;
var cards = indices
.Select(index => Card.ToCard(Hand[index]))
.ToList();
bool matchingShapes = cards.Select(card => card.Shape).Distinct().Count() == 1;
bool matchingColors = cards.Select(card => card.Color).Distinct().Count() == 1;
bool matchingShades = cards.Select(card => card.Shade).Distinct().Count() == 1;
bool matchingCounts = cards.Select(card => card.Count).Distinct().Count() == 1;
if (!matchingShapes && !matchingColors && !matchingShades && !matchingCounts) {
Fails += 1;
return new SetCheckResult { IsSet = false, NewState = this };
}
// Order by Descending to make sure the correct card is removed
// (From the top may cause the indices to be off by one)
foreach (var index in indices.OrderByDescending(i => i)) {
ReplaceCardInHand(index);
}
if (GameIsFinished()) {
FinishedAt = DateTime.Now;
}
return new SetCheckResult { IsSet = true, NewState = this };
}
}

View File

@@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore;
namespace backend.Models;
public class GameContext : DbContext
{
public GameContext(DbContextOptions<GameContext> options) : base(options) {
}
public DbSet<Game> Games { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder) {
modelBuilder.Entity<Game>()
.Property(b => b.StartedAt)
.HasDefaultValueSql("CURRENT_TIMESTAMP");
}
}

45
backend/backend/Program.cs Executable file
View File

@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore;
using backend.Models;
using DotNetEnv;
Env.Load();
var builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration
.AddEnvironmentVariables()
.Build();
builder.Services.AddOpenApi();
builder.Services.AddControllers();
var postgres_connection_string = $@"User ID={config["DB_USER"]};
Password={config["DB_PASS"]};
Host={config["DB_HOST"]};
Port={config["DB_PORT"]};
Database={config["DB_NAME"]};
Connection Lifetime=0;";
builder.Services.AddDbContext<GameContext>(opt => opt.UseNpgsql(postgres_connection_string));
var app = builder.Build();
// Disable CORS
app.Logger.LogInformation("Disabling CORS to allow communication with frontend");
app.UseCors(builder => {
builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
if (app.Environment.IsDevelopment()) {
app.Logger.LogInformation("Enabling Swagger UI for development");
app.MapOpenApi();
app.UseSwaggerUi(options => options.DocumentPath = "/openapi/v1.json");
}
app.Logger.LogInformation("Creating middleware");
// app.UseHttpsRedirection();
// app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5224",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7205;http://localhost:5224",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

38
backend/backend/backend.csproj Executable file
View File

@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DotNetEnv" Version="3.1.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.3" />
<PackageReference Include="NSwag.AspNetCore" Version="14.2.0" />
</ItemGroup>
<ItemGroup>
<Folder Include="Controllers\" />
</ItemGroup>
<ItemGroup>
<None Update=".env">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

6
backend/backend/backend.http Executable file
View File

@@ -0,0 +1,6 @@
@backend_HostAddress = http://localhost:5224
GET {{backend_HostAddress}}/weatherforecast/
Accept: application/json
###