Compare commits
3 commits
b6a3226e3d
...
766aabeae3
| Author | SHA1 | Date | |
|---|---|---|---|
| 766aabeae3 | |||
| dbc1257dd6 | |||
| bfe26d76c1 |
5 changed files with 56 additions and 77 deletions
38
CHANGELOG.md
38
CHANGELOG.md
|
|
@ -1,5 +1,43 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
# v0.2.47
|
||||||
|
|
||||||
|
Currency displays get a finishing touch.
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- Banking balances now render the currency's emblem image inline, with an option to hide the label where space is tight.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.2.46
|
||||||
|
|
||||||
|
The world map gets a front door.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Viewable World Map**: You can now view the World Map, which includes maps for various regions (for now, only Antarctica.) Cards show the map's imagery, campaign, weather, ease of access, controlling faction, and a short description; click one to load it.
|
||||||
|
- **Node icons**: pick from ten marker icons when authoring map nodes.
|
||||||
|
- **Plain markers**: map nodes no longer require resources, so they can serve as simple landmarks.
|
||||||
|
- **Supply boxes**: a new management surface for supply crates and their contents.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.2.45
|
||||||
|
|
||||||
|
Bases, operations, and shipments grow real depth.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Base production**: structures run production recipes with inputs, outputs, and material costs. Module slots add capacity, output, and storage bonuses, and well-placed bases project adjacency auras onto their neighbors.
|
||||||
|
- **Compounds**: attach buildings to a hub so their storage pools together, with the map showing compound totals on the ribbon.
|
||||||
|
- **Crew speed**: construction duration scales with the crew you staff, and headcount changes re-price the remaining work.
|
||||||
|
- **Operation reservations**: reserve vehicles, supports, and other assets, all from new operations screens with readiness and zone pressure readouts.
|
||||||
|
- **Live shipments**: shipment statuses now update the moment they're due (in transit, arrival, completion) instead of waiting for the next game tick.
|
||||||
|
- **Map polish**: zone names label the map with smarter placement, a political zone type joins the palette, and mission pages get a cleaner back-link.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# v0.2.38
|
# v0.2.38
|
||||||
|
|
||||||
Blackjack arrives as a full table game with its own economy: every minigame now feeds a persistent chip bank you spend on buy-ins at the card tables. Poker gains named tables, popup history, and dev controls; mission briefings and side ops move to a proper rich text editor, and rank promotions get a ceiling-based authority model.
|
Blackjack arrives as a full table game with its own economy: every minigame now feeds a persistent chip bank you spend on buy-ins at the card tables. Poker gains named tables, popup history, and dev controls; mission briefings and side ops move to a proper rich text editor, and rank promotions get a ceiling-based authority model.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "polaris-task-force",
|
"name": "polaris-task-force",
|
||||||
"version": "0.2.48",
|
"version": "0.2.49",
|
||||||
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,14 @@
|
||||||
*
|
*
|
||||||
* Sources (all post-settlement, never live/unsettled data):
|
* Sources (all post-settlement, never live/unsettled data):
|
||||||
* - participation: applied `operation-effects` reached via their provenance
|
* - participation: applied `operation-effects` reached via their provenance
|
||||||
* link (mission-attendance -> operation-events -> operation-effects).
|
* link (operation-events -> operation-effects) for the subject's actor id.
|
||||||
* - attendance: `mission-attendances` "yes" for the subject.
|
|
||||||
* - assignments: `assignments` the subject belongs to.
|
* - assignments: `assignments` the subject belongs to.
|
||||||
* - evaluation: metadata-only (performance level) aggregate of evaluations
|
* - evaluation: metadata-only (performance level) aggregate of evaluations
|
||||||
* where the subject is the ratee. Comment/rater are never read.
|
* where the subject is the ratee. Comment/rater are never read.
|
||||||
* - objectives: validated `operation.objective` events for the subject.
|
* - objectives: validated `operation.objective` events for the subject.
|
||||||
* - logistics: completed `shipments` created by the subject.
|
* - logistics: completed `shipments` created by the subject.
|
||||||
*/
|
*/
|
||||||
import type { Payload, PayloadRequest, Where } from "payload";
|
import type { Payload, PayloadRequest } from "payload";
|
||||||
import type { OperationReadiness } from "@/payload-types";
|
import type { OperationReadiness } from "@/payload-types";
|
||||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
import { Permission } from "@/permissions";
|
import { Permission } from "@/permissions";
|
||||||
|
|
@ -54,15 +53,6 @@ export interface ReadinessReadResult {
|
||||||
data: unknown | null;
|
data: unknown | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function relationId(value: unknown): number | null {
|
|
||||||
if (typeof value === "number") return value;
|
|
||||||
if (value !== null && typeof value === "object" && "id" in (value as { id: unknown })) {
|
|
||||||
const id = (value as { id: unknown }).id;
|
|
||||||
return typeof id === "number" ? id : null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gather normalized per-category inputs for a subject over a window. Every query
|
* Gather normalized per-category inputs for a subject over a window. Every query
|
||||||
* is bounded and read-only; the evaluation path reads only the performance level
|
* is bounded and read-only; the evaluation path reads only the performance level
|
||||||
|
|
@ -75,36 +65,17 @@ export async function gatherReadinessInputs(
|
||||||
): Promise<ReadinessInputs> {
|
): Promise<ReadinessInputs> {
|
||||||
const inWindow = { greater_than_equal: scope.windowStart.toISOString(), less_than_equal: scope.windowEnd.toISOString() };
|
const inWindow = { greater_than_equal: scope.windowStart.toISOString(), less_than_equal: scope.windowEnd.toISOString() };
|
||||||
|
|
||||||
const attended = await payload.find({
|
// Operation participation via provenance link: validated
|
||||||
collection: "mission-attendances",
|
// operation-events -> applied operation-effects for the subject's actor id.
|
||||||
where: {
|
|
||||||
and: [
|
|
||||||
{ user: { equals: scope.subjectId } },
|
|
||||||
{ response: { equals: "yes" } },
|
|
||||||
{ createdAt: inWindow },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
limit: 500,
|
|
||||||
depth: 0,
|
|
||||||
overrideAccess: true,
|
|
||||||
req,
|
|
||||||
});
|
|
||||||
const attendanceYes = attended.docs.length;
|
|
||||||
const attendedMissionIds = attended.docs
|
|
||||||
.map((d) => relationId((d as { mission: unknown }).mission))
|
|
||||||
.filter((id): id is number => id !== null);
|
|
||||||
|
|
||||||
// Operation participation via provenance link: mission -> validated
|
|
||||||
// operation-events -> applied operation-effects. The explicit `Where`
|
|
||||||
// annotation gives the ternary a contextual type, avoiding the
|
|
||||||
// discriminated-union-with-undefined-props shape Payload's Where rejects.
|
|
||||||
const eventsWhere: Where =
|
|
||||||
attendedMissionIds.length > 0
|
|
||||||
? { or: [{ actor: { equals: scope.subjectId } }, { mission: { in: attendedMissionIds } }] }
|
|
||||||
: { actor: { equals: scope.subjectId } };
|
|
||||||
const events = await payload.find({
|
const events = await payload.find({
|
||||||
collection: "operation-events",
|
collection: "operation-events",
|
||||||
where: { and: [{ status: { equals: "validated" } }, { occurredAt: inWindow }, eventsWhere] },
|
where: {
|
||||||
|
and: [
|
||||||
|
{ status: { equals: "validated" } },
|
||||||
|
{ occurredAt: inWindow },
|
||||||
|
{ actor: { equals: scope.subjectId } },
|
||||||
|
],
|
||||||
|
},
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
|
|
@ -131,9 +102,7 @@ export async function gatherReadinessInputs(
|
||||||
{ status: { equals: "validated" } },
|
{ status: { equals: "validated" } },
|
||||||
{ type: { equals: "operation.objective" } },
|
{ type: { equals: "operation.objective" } },
|
||||||
{ occurredAt: inWindow },
|
{ occurredAt: inWindow },
|
||||||
attendedMissionIds.length > 0
|
{ actor: { equals: scope.subjectId } },
|
||||||
? { or: [{ actor: { equals: scope.subjectId } }, { mission: { in: attendedMissionIds } }] }
|
|
||||||
: { actor: { equals: scope.subjectId } },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
limit: 500,
|
limit: 500,
|
||||||
|
|
@ -196,7 +165,6 @@ export async function gatherReadinessInputs(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
participation: countScore(READINESS_CATEGORIES.PARTICIPATION, participationCount, 25),
|
participation: countScore(READINESS_CATEGORIES.PARTICIPATION, participationCount, 25),
|
||||||
attendance: countScore(READINESS_CATEGORIES.ATTENDANCE, attendanceYes, 34),
|
|
||||||
assignments: countScore(READINESS_CATEGORIES.ASSIGNMENTS, assignments.docs.length, 25),
|
assignments: countScore(READINESS_CATEGORIES.ASSIGNMENTS, assignments.docs.length, 25),
|
||||||
evaluation:
|
evaluation:
|
||||||
evaluationScore === null
|
evaluationScore === null
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export type ReadinessSubjectType = typeof READINESS_SUBJECT[keyof typeof READINE
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contributing categories and their declared weights. The weights are a fixed,
|
* Contributing categories and their declared weights. The weights are a fixed,
|
||||||
* documented ratio (they sum to 130; the aggregate normalizes over the sum of
|
* documented ratio (they sum to 110; the aggregate normalizes over the sum of
|
||||||
* the *present* categories' weights). They are NOT tunable per-subject, so the
|
* the *present* categories' weights). They are NOT tunable per-subject, so the
|
||||||
* model stays transparent with no hidden weighting. The category name is the
|
* model stays transparent with no hidden weighting. The category name is the
|
||||||
* only thing surfaced in the explanation -- never evaluator names, ids, or
|
* only thing surfaced in the explanation -- never evaluator names, ids, or
|
||||||
|
|
@ -47,7 +47,6 @@ export type ReadinessSubjectType = typeof READINESS_SUBJECT[keyof typeof READINE
|
||||||
*/
|
*/
|
||||||
export const READINESS_CATEGORIES = {
|
export const READINESS_CATEGORIES = {
|
||||||
PARTICIPATION: "participation",
|
PARTICIPATION: "participation",
|
||||||
ATTENDANCE: "attendance",
|
|
||||||
ASSIGNMENTS: "assignments",
|
ASSIGNMENTS: "assignments",
|
||||||
EVALUATION: "evaluation",
|
EVALUATION: "evaluation",
|
||||||
OBJECTIVES: "objectives",
|
OBJECTIVES: "objectives",
|
||||||
|
|
@ -57,7 +56,6 @@ export type ReadinessCategory = typeof READINESS_CATEGORIES[keyof typeof READINE
|
||||||
|
|
||||||
export const READINESS_CATEGORY_WEIGHTS: Record<ReadinessCategory, number> = {
|
export const READINESS_CATEGORY_WEIGHTS: Record<ReadinessCategory, number> = {
|
||||||
participation: 25,
|
participation: 25,
|
||||||
attendance: 20,
|
|
||||||
assignments: 25,
|
assignments: 25,
|
||||||
evaluation: 30,
|
evaluation: 30,
|
||||||
objectives: 15,
|
objectives: 15,
|
||||||
|
|
@ -67,7 +65,6 @@ export const READINESS_CATEGORY_WEIGHTS: Record<ReadinessCategory, number> = {
|
||||||
/** Human-readable label for a contributing category (explanation text only). */
|
/** Human-readable label for a contributing category (explanation text only). */
|
||||||
export const READINESS_CATEGORY_LABELS: Record<ReadinessCategory, string> = {
|
export const READINESS_CATEGORY_LABELS: Record<ReadinessCategory, string> = {
|
||||||
participation: "Operation participation",
|
participation: "Operation participation",
|
||||||
attendance: "Attendance",
|
|
||||||
assignments: "Assignments",
|
assignments: "Assignments",
|
||||||
evaluation: "Leadership evaluation",
|
evaluation: "Leadership evaluation",
|
||||||
objectives: "Completed objectives",
|
objectives: "Completed objectives",
|
||||||
|
|
@ -79,7 +76,7 @@ export const READINESS_CATEGORY_LABELS: Record<ReadinessCategory, string> = {
|
||||||
* the signals commanders rely on when planning, distinct from the broader
|
* the signals commanders rely on when planning, distinct from the broader
|
||||||
* readiness measure.
|
* readiness measure.
|
||||||
*/
|
*/
|
||||||
export const PLANNING_CATEGORIES: readonly ReadinessCategory[] = ["attendance", "assignments"];
|
export const PLANNING_CATEGORIES: readonly ReadinessCategory[] = ["assignments"];
|
||||||
|
|
||||||
/** Normalized per-category input fed to the pure computation. */
|
/** Normalized per-category input fed to the pure computation. */
|
||||||
export interface ReadinessCategoryInput {
|
export interface ReadinessCategoryInput {
|
||||||
|
|
@ -93,7 +90,6 @@ export interface ReadinessCategoryInput {
|
||||||
|
|
||||||
export interface ReadinessInputs {
|
export interface ReadinessInputs {
|
||||||
participation: ReadinessCategoryInput;
|
participation: ReadinessCategoryInput;
|
||||||
attendance: ReadinessCategoryInput;
|
|
||||||
assignments: ReadinessCategoryInput;
|
assignments: ReadinessCategoryInput;
|
||||||
evaluation: ReadinessCategoryInput;
|
evaluation: ReadinessCategoryInput;
|
||||||
objectives: ReadinessCategoryInput;
|
objectives: ReadinessCategoryInput;
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ const opEffectIds: number[] = [];
|
||||||
const userIds: number[] = [];
|
const userIds: number[] = [];
|
||||||
const roleIds: number[] = [];
|
const roleIds: number[] = [];
|
||||||
const missionIds: number[] = [];
|
const missionIds: number[] = [];
|
||||||
const attendanceIds: number[] = [];
|
|
||||||
const evaluationIds: number[] = [];
|
const evaluationIds: number[] = [];
|
||||||
const campaignIds: number[] = [];
|
const campaignIds: number[] = [];
|
||||||
const mapIds: number[] = [];
|
const mapIds: number[] = [];
|
||||||
|
|
@ -82,17 +81,6 @@ async function makeMission(label: string, authorId: number): Promise<number> {
|
||||||
return mission.id;
|
return mission.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function makeAttendance(missionId: number, userId: number): Promise<number> {
|
|
||||||
const doc = await payload.create({
|
|
||||||
collection: "mission-attendances",
|
|
||||||
data: { mission: missionId, user: userId, response: "yes" },
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
});
|
|
||||||
attendanceIds.push(doc.id);
|
|
||||||
return doc.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function makeEvaluation(missionId: number, ratee: number): Promise<number> {
|
async function makeEvaluation(missionId: number, ratee: number): Promise<number> {
|
||||||
const doc = await payload.create({
|
const doc = await payload.create({
|
||||||
collection: "evaluations",
|
collection: "evaluations",
|
||||||
|
|
@ -229,9 +217,6 @@ afterAll(async () => {
|
||||||
for (const id of evaluationIds) {
|
for (const id of evaluationIds) {
|
||||||
await payload.delete({ collection: "evaluations", id, overrideAccess: true }).catch(() => {});
|
await payload.delete({ collection: "evaluations", id, overrideAccess: true }).catch(() => {});
|
||||||
}
|
}
|
||||||
for (const id of attendanceIds) {
|
|
||||||
await payload.delete({ collection: "mission-attendances", id, overrideAccess: true }).catch(() => {});
|
|
||||||
}
|
|
||||||
for (const id of missionIds) {
|
for (const id of missionIds) {
|
||||||
await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {});
|
await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
@ -255,7 +240,6 @@ afterAll(async () => {
|
||||||
describe("Operation readiness", () => {
|
describe("Operation readiness", () => {
|
||||||
it("computes a deterministic aggregate from window inputs and stores it with an explanation", async () => {
|
it("computes a deterministic aggregate from window inputs and stores it with an explanation", async () => {
|
||||||
const mission = await makeMission("Alpha", adminId);
|
const mission = await makeMission("Alpha", adminId);
|
||||||
await makeAttendance(mission, subjectId);
|
|
||||||
await makeEvaluation(mission, subjectId);
|
await makeEvaluation(mission, subjectId);
|
||||||
await makeObjectiveEvent("obj-1", subjectId);
|
await makeObjectiveEvent("obj-1", subjectId);
|
||||||
|
|
||||||
|
|
@ -272,20 +256,14 @@ describe("Operation readiness", () => {
|
||||||
readinessIds.push(result.id);
|
readinessIds.push(result.id);
|
||||||
|
|
||||||
const categories = result.contributingCategories.map((c) => c.category);
|
const categories = result.contributingCategories.map((c) => c.category);
|
||||||
expect(categories).toContain("attendance");
|
|
||||||
expect(categories).toContain("evaluation");
|
expect(categories).toContain("evaluation");
|
||||||
expect(categories).toContain("objectives");
|
expect(categories).toContain("objectives");
|
||||||
expect(result.calculationVersion).toBe(READINESS_CALCULATION_VERSION);
|
expect(result.calculationVersion).toBe(READINESS_CALCULATION_VERSION);
|
||||||
expect(result.nature).toBe("advisory");
|
expect(result.nature).toBe("advisory");
|
||||||
|
|
||||||
const attendance = result.contributingCategories.find((c) => c.category === "attendance");
|
|
||||||
expect(attendance?.inputCount).toBe(1);
|
|
||||||
expect(attendance?.score).toBe(34);
|
|
||||||
|
|
||||||
const evaluation = result.contributingCategories.find((c) => c.category === "evaluation");
|
const evaluation = result.contributingCategories.find((c) => c.category === "evaluation");
|
||||||
expect(evaluation?.score).toBe(70);
|
expect(evaluation?.score).toBe(70);
|
||||||
|
|
||||||
expect(result.explanation).toContain("Attendance");
|
|
||||||
expect(result.explanation).not.toContain(raterDisplayName());
|
expect(result.explanation).not.toContain(raterDisplayName());
|
||||||
expect(result.explanation).not.toContain("evaluator");
|
expect(result.explanation).not.toContain("evaluator");
|
||||||
});
|
});
|
||||||
|
|
@ -301,8 +279,7 @@ describe("Operation readiness", () => {
|
||||||
const scope = scopeFor(windowStart, windowEnd);
|
const scope = scopeFor(windowStart, windowEnd);
|
||||||
const before = (await computeReadiness(payload, scope)) as { id: number; readinessScore: number };
|
const before = (await computeReadiness(payload, scope)) as { id: number; readinessScore: number };
|
||||||
|
|
||||||
const missions = missionIds;
|
await makeObjectiveEvent("obj-2", subjectId);
|
||||||
await makeAttendance(missions[0], subjectId);
|
|
||||||
|
|
||||||
const after = (await computeReadiness(payload, scope)) as {
|
const after = (await computeReadiness(payload, scope)) as {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -310,8 +287,8 @@ describe("Operation readiness", () => {
|
||||||
contributingCategories: { category: string; inputCount: number }[];
|
contributingCategories: { category: string; inputCount: number }[];
|
||||||
};
|
};
|
||||||
expect(after.id).toBe(before.id);
|
expect(after.id).toBe(before.id);
|
||||||
const attendance = after.contributingCategories.find((c) => c.category === "attendance");
|
const objectives = after.contributingCategories.find((c) => c.category === "objectives");
|
||||||
expect(attendance?.inputCount).toBe(2);
|
expect(objectives?.inputCount).toBe(2);
|
||||||
expect(after.readinessScore).not.toBe(before.readinessScore);
|
expect(after.readinessScore).not.toBe(before.readinessScore);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue